From c8628ddea95352cbcf7efe8e05a01dc2f6bf6ec9 Mon Sep 17 00:00:00 2001 From: seongwon seo Date: Mon, 17 Aug 2026 18:05:45 +0900 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20sub?= =?UTF-8?q?=EB=A5=BC=20=ED=86=A0=ED=81=B0=20=EC=95=88=EC=9D=98=20sub?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=B4=EB=82=B4=EA=B3=A0=20CSPRNG=EB=A1=9C=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 서버가 요청 sub를 무시하고 자체 UUID로 신원을 만들어 왔기 때문에, 기존 설치는 저장된 @auth_subject와 토큰 안의 신원이 서로 다른 값이다. 백엔드가 요청 sub를 studentId로 쓰기 시작하면, 401 재발급 때 @auth_subject를 보내는 현재 동작은 신원을 바꿔버려 기존 편지함을 잃게 만든다. - resolveAuthSubject()를 추가해 저장된 토큰이 있으면 payload.sub를, 없을 때만 @auth_subject를 쓰도록 한다. api.ts의 401 재발급 경로와 auth-token.service.ts의 발급 경로가 같은 규칙을 쓴다. - generateUuidV4를 Math.random에서 crypto.getRandomValues로 바꾼다. 서버가 sub를 신원으로 받기 시작하면 이 값이 곧 신원 증명이 되므로 추측 가능하면 안 된다. polyfill이 없으면 약한 값으로 폴백하지 않고 실패시킨다. @auth_subject 저장 키와 기존 값은 그대로 둔다. Co-Authored-By: Claude Opus 5 (1M context) --- services/api.ts | 4 ++-- services/auth-token-storage.ts | 34 ++++++++++++++++++++++++++++------ services/auth-token.service.ts | 4 ++-- 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/services/api.ts b/services/api.ts index 5a886e2..b470690 100644 --- a/services/api.ts +++ b/services/api.ts @@ -6,7 +6,7 @@ import { ApiErrorResponse } from '@/types/club.types'; import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; import * as Application from 'expo-application'; import Constants from 'expo-constants'; -import { getOrCreateAuthSubject, getStoredAccessToken, saveAccessToken } from './auth-token-storage'; +import { getStoredAccessToken, resolveAuthSubject, saveAccessToken } from './auth-token-storage'; const APP_VERSION_HEADER_KEY = 'APP_VERSION'; @@ -160,7 +160,7 @@ function attachInterceptors(client: AxiosInstance, options: { withAuth: boolean if (!refreshTokenPromise) { refreshTokenPromise = (async () => { try { - const sub = await getOrCreateAuthSubject(); + const sub = await resolveAuthSubject(); const iat = Math.floor(Date.now() / 1000); const response = await publicApiClient.post('/auth/student', { sub, iat }); const nextToken = extractAccessToken(response.data); diff --git a/services/auth-token-storage.ts b/services/auth-token-storage.ts index 0d9220a..b01d929 100644 --- a/services/auth-token-storage.ts +++ b/services/auth-token-storage.ts @@ -6,13 +6,20 @@ const AUTH_SUBJECT_KEY = '@auth_subject'; let cachedAccessToken: string | null | undefined; let cachedAuthSubject: string | null | undefined; +// sub는 서버가 발급하는 신원(studentId)의 근거가 되므로 추측 가능한 값이면 안 된다. +// crypto.getRandomValues는 app/_layout.tsx의 react-native-get-random-values로 폴리필된다. function generateUuidV4(): string { - const template = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'; - return template.replace(/[xy]/g, (char) => { - const random = Math.floor(Math.random() * 16); - const value = char === 'x' ? random : (random & 0x3) | 0x8; - return value.toString(16); - }); + if (typeof globalThis.crypto?.getRandomValues !== 'function') { + throw new Error('crypto.getRandomValues를 사용할 수 없어 auth subject를 생성할 수 없습니다.'); + } + + const bytes = new Uint8Array(16); + globalThis.crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 0x0f) | 0x40; // version 4 + bytes[8] = (bytes[8] & 0x3f) | 0x80; // variant 10 + + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join(''); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; } export async function getStoredAccessToken(): Promise { @@ -87,3 +94,18 @@ export function getJwtSubject(accessToken: string): string | null { return null; } } + +/** + * /auth/student 요청 본문에 실을 sub. + * + * 서버가 요청 sub를 무시하고 자체 UUID로 신원을 만들던 시절이 있어서, 기존 설치는 + * 저장된 @auth_subject와 토큰 안의 신원이 서로 다른 값이다. 실제 편지함이 달린 신원은 + * 토큰 쪽이므로, 저장된 토큰이 있으면 그 payload.sub를 그대로 다시 보낸다. + * 토큰이 없는 신규 설치에서만 @auth_subject를 쓴다. + */ +export async function resolveAuthSubject(): Promise { + const accessToken = await getStoredAccessToken(); + const tokenSubject = accessToken ? getJwtSubject(accessToken) : null; + + return tokenSubject ?? getOrCreateAuthSubject(); +} diff --git a/services/auth-token.service.ts b/services/auth-token.service.ts index 922f69c..1f05fcd 100644 --- a/services/auth-token.service.ts +++ b/services/auth-token.service.ts @@ -1,5 +1,5 @@ import { publicApi } from './api'; -import { getOrCreateAuthSubject, getStoredAccessToken, saveAccessToken } from './auth-token-storage'; +import { getStoredAccessToken, resolveAuthSubject, saveAccessToken } from './auth-token-storage'; type IssueAccessTokenResponse = | { @@ -46,7 +46,7 @@ function extractAccessToken(response: IssueAccessTokenResponse): string | null { export async function issueAccessToken(): Promise { const payload: IssueAccessTokenPayload = { - sub: await getOrCreateAuthSubject(), + sub: await resolveAuthSubject(), iat: Math.floor(Date.now() / 1000), }; From 8135024f896a525e15cf8b4a8f4acbecff58d1e3 Mon Sep 17 00:00:00 2001 From: SeongHoonC Date: Tue, 18 Aug 2026 20:10:34 +0900 Subject: [PATCH 2/2] chore: bump app version to 1.7.1 --- app.json | 6 +++--- ios/app.xcodeproj/project.pbxproj | 8 ++++---- ios/app/Info.plist | 4 ++-- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/app.json b/app.json index a20dee2..30a2a8d 100644 --- a/app.json +++ b/app.json @@ -2,7 +2,7 @@ "expo": { "name": "모아동", "slug": "moadong-app", - "version": "1.7.0", + "version": "1.7.1", "orientation": "portrait", "icon": "./assets/images/icon.png", "scheme": "moadongapp", @@ -10,7 +10,7 @@ "newArchEnabled": true, "ios": { "supportsTablet": false, - "buildNumber": "17", + "buildNumber": "18", "googleServicesFile": "./GoogleService-Info.plist", "bundleIdentifier": "com.moadong.moadong", "associatedDomains": [ @@ -26,7 +26,7 @@ }, "android": { "jsEngine": "hermes", - "versionCode": 17, + "versionCode": 18, "adaptiveIcon": { "backgroundColor": "#E6F4FE", "foregroundImage": "./assets/images/android-icon-foreground.png", diff --git a/ios/app.xcodeproj/project.pbxproj b/ios/app.xcodeproj/project.pbxproj index 82e08c2..bb22420 100644 --- a/ios/app.xcodeproj/project.pbxproj +++ b/ios/app.xcodeproj/project.pbxproj @@ -417,7 +417,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = 2QMK9GBWN6; ENABLE_BITCODE = NO; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -430,7 +430,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.0; + MARKETING_VERSION = 1.7.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", @@ -454,7 +454,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = app/app.entitlements; - CURRENT_PROJECT_VERSION = 17; + CURRENT_PROJECT_VERSION = 18; DEVELOPMENT_TEAM = 2QMK9GBWN6; INFOPLIST_FILE = app/Info.plist; IPHONEOS_DEPLOYMENT_TARGET = 15.1; @@ -462,7 +462,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.7.0; + MARKETING_VERSION = 1.7.1; OTHER_LDFLAGS = ( "$(inherited)", "-ObjC", diff --git a/ios/app/Info.plist b/ios/app/Info.plist index bde57a7..dfb41e5 100644 --- a/ios/app/Info.plist +++ b/ios/app/Info.plist @@ -19,7 +19,7 @@ CFBundlePackageType $(PRODUCT_BUNDLE_PACKAGE_TYPE) CFBundleShortVersionString - 1.7.0 + 1.7.1 CFBundleSignature ???? CFBundleURLTypes @@ -39,7 +39,7 @@ CFBundleVersion - 17 + 18 LSMinimumSystemVersion 12.0 LSRequiresIPhoneOS