diff --git a/app/.gitignore b/app/.gitignore index e095863..0228ebc 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -78,3 +78,6 @@ docs/output/ # Cloud Functions 의 tsc 산출물. 소스는 firebase/functions/src 다. firebase/functions/lib/ + +# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다. +config/kakao.env diff --git a/app/BUILD.md b/app/BUILD.md index cff7dff..ce1e8d6 100644 --- a/app/BUILD.md +++ b/app/BUILD.md @@ -138,6 +138,13 @@ python3 tools/ios/inject_url_scheme.py # → ios/config/Google.xcconfig > `apps:sdkconfig` 는 대상 파일이 이미 있으면 거부한다. 갱신하려면 먼저 지운다. +**카카오 네이티브 앱 키** — `config/kakao.env`(저장소 밖, `config/kakao.env.example` 복사) 한 줄 +`FANUZU_KAKAO_NATIVE_APP_KEY=<키>`. 주석을 넣지 않는다(iOS xcconfig 가 그대로 include 한다). +Android `build.gradle.kts`(리다이렉트 스킴) · iOS `Flutter/*.xcconfig`(URL 스킴) 가 이 파일을 읽고, +Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받는다. 없으면 카카오 로그인만 +「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>` +의 `KAKAO_APP_ID` 로 받는다. + Authentication 제공자는 이메일/비밀번호와 Google 둘 다 켜져 있다(2026-09-11). Google 을 켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다. diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index d3c1fbe..639f9e6 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -17,6 +17,14 @@ val keystoreProperties = Properties().apply { if (f.exists()) f.inputStream().use { load(it) } } +// 카카오 네이티브 앱 키 — `config/kakao.env` 한 파일이 Dart(--dart-define-from-file)· +// Android(여기)·iOS(Flutter/*.xcconfig)에 같은 값을 준다. 파일은 저장소에 넣지 않는다. +// 없으면 빈 값으로 빌드되고 카카오 로그인만 「설정 전」으로 막힌다. +val kakaoProperties = Properties().apply { + val f = rootProject.file("../config/kakao.env") + if (f.exists()) f.inputStream().use { load(it) } +} + /// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가. val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) } @@ -53,6 +61,9 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + // AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`. + manifestPlaceholders["kakaoNativeAppKey"] = + kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "") } signingConfigs { diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index ae0ae0a..4fc36d3 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -48,6 +48,18 @@ + + + + + + + + + ` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명). + * + * 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고 + * `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다. + */ + +export const KAKAO_PROVIDER_ID = 'oidc.kakao'; + +/** 카카오 Developers 「앱 ID」(숫자). 비밀값이 아니다 — 토큰이 우리 앱에서 발급됐는지 본다. */ +const kakaoAppId = defineString('KAKAO_APP_ID', { default: '' }); + +export interface KakaoProfile { + /** 카카오 회원번호. */ + id: string; + nickname?: string; + /** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */ + email?: string; +} + +export const kakaoUidOf = (kakaoId: string) => `kakao:${kakaoId}`; + +/** + * 카카오 액세스 토큰을 카카오 API 로 검증한다. + * + * 1. `/v1/user/access_token_info` — 유효성 + **우리 앱 ID 로 발급됐는가**. 남의 앱에서 받은 + * 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을 막는 지점이다. + * 2. `/v2/user/me` — 닉네임·이메일. 회원번호가 1 과 같아야 한다. + */ +export async function verifyKakaoToken(accessToken: string, appId: string): Promise { + if (!appId) fail(ERR.authFailed, '카카오 로그인 설정이 끝나지 않았습니다.'); + const get = async (path: string) => { + const res = await fetch(`https://kapi.kakao.com${path}`, { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + if (!res.ok) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.'); + return (await res.json()) as Record; + }; + const info = await get('/v1/user/access_token_info'); + if (String(info.app_id) !== appId) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.'); + const me = await get('/v2/user/me'); + if (String(me.id) !== String(info.id)) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.'); + + const account = (me.kakao_account ?? {}) as Record; + const profile = (account.profile ?? {}) as Record; + const email = account.is_email_valid === true && account.is_email_verified === true + && typeof account.email === 'string' ? account.email : undefined; + const nickname = typeof profile.nickname === 'string' ? profile.nickname : undefined; + return { + id: String(info.id), + ...(nickname ? { nickname } : {}), + ...(email ? { email } : {}), + }; +} + +/** 이 카카오 계정이 붙은 Firebase UID. 없으면 null. */ +async function ownerOf(kakaoId: string): Promise { + try { + return (await getAuth().getUserByProviderUid(KAKAO_PROVIDER_ID, kakaoId)).uid; + } catch (e) { + if ((e as { code?: string }).code === 'auth/user-not-found') return null; + throw e; + } +} + +function linkInfo(p: KakaoProfile) { + return { + providerId: KAKAO_PROVIDER_ID, + uid: p.id, + ...(p.nickname ? { displayName: p.nickname } : {}), + ...(p.email ? { email: p.email } : {}), + }; +} + +/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `kakao:` 를 만들고 연결한다. */ +export async function resolveKakaoUid(p: KakaoProfile): Promise { + const existing = await ownerOf(p.id); + if (existing) return existing; + const uid = kakaoUidOf(p.id); + const auth = getAuth(); + try { + await auth.createUser({ uid, ...(p.nickname ? { displayName: p.nickname } : {}) }); + } catch (e) { + // 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다. + if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e; + } + await auth.updateUser(uid, { providerToLink: linkInfo(p) }); + return uid; +} + +/** + * SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않는다. + * + * 「동일 이메일만 연동」(명세 `554:22797`)은 붙이기 **전에** 서버에서 본다. 둘 중 하나라도 + * 이메일이 없으면 건너뛴다(앱과 같은 규칙 — 카카오 이메일은 비즈 앱 동의가 있을 때만 온다). + */ +export async function linkKakao(uid: string, p: KakaoProfile): Promise { + const owner = await ownerOf(p.id); + if (owner === uid) return; + if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); + const user = await getAuth().getUser(uid); + if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) { + fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.'); + } + const mine = user.email?.trim().toLowerCase(); + const theirs = p.email?.trim().toLowerCase(); + if (mine && theirs && mine !== theirs) { + fail(ERR.authCredentialInUse, '같은 이메일의 계정만 연결할 수 있습니다.', { reason: 'different-email' }); + } + await getAuth().updateUser(uid, { providerToLink: linkInfo(p) }); +} + +export const fnAuthKakao = onCall(async (req) => { + const profile = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value()); + if ((req.data as Record).link === true) { + const uid = req.auth?.uid; + if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.'); + await linkKakao(uid, profile); + return { linked: true }; + } + const uid = await resolveKakaoUid(profile); + return { token: await getAuth().createCustomToken(uid) }; +}); diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index 7fa0272..4c247eb 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -35,6 +35,9 @@ export const ERR = { primaryCooldown: 'E-FDM-04', permissionDenied: 'E-PERM-01', authFailed: 'E-AUTH-03', + // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다. + authCredentialInUse: 'E-AUTH-08', + authAlreadyLinked: 'E-AUTH-09', // 참여형 번역(FR-I18N-02). E-TRN-01 은 정책 설계서 §P-13 이 「본인 번역 마킹 금지」로 이미 정했다. trnSelfMark: 'E-TRN-01', trnDailyCap: 'E-TRN-02', @@ -76,6 +79,8 @@ const STATUS: Record = { 'E-FDM-04': 'failed-precondition', 'E-PERM-01': 'permission-denied', 'E-AUTH-03': 'permission-denied', + 'E-AUTH-08': 'already-exists', + 'E-AUTH-09': 'already-exists', 'E-TRN-01': 'permission-denied', 'E-TRN-02': 'resource-exhausted', 'E-TRN-03': 'not-found', diff --git a/app/firebase/functions/src/fandom/evolution.ts b/app/firebase/functions/src/fandom/evolution.ts index 7c5921a..2681138 100644 --- a/app/firebase/functions/src/fandom/evolution.ts +++ b/app/firebase/functions/src/fandom/evolution.ts @@ -1,4 +1,11 @@ import { onDocumentUpdated } from 'firebase-functions/v2/firestore'; +import { onCall } from 'firebase-functions/v2/https'; +import { FieldValue, getFirestore } from 'firebase-admin/firestore'; +import { logger } from 'firebase-functions'; + +import { callerUid, requireRole, str } from '../core/callable.js'; +import { ERR, fail } from '../core/errors.js'; +import { sendFandomPush } from '../notify/push.js'; /** v1.5 §2.1/§7: 확정 Stage 상승을 보존한다. 개인 참여/보상을 추정하지 않는다. */ export const fnPlanetEvolutionRecord = onDocumentUpdated( @@ -30,3 +37,93 @@ export const fnPlanetEvolutionRecord = onDocumentUpdated( } }, ); + +/** 별첨 B **B-53** — 정식 행성 6 단계 명칭. 성운(Stage0)은 `fn-planet-create` 가 다룬다. */ +const STAGE_NAMES = ['', '시작', '성장', '함께', '동행', '빛', '영원']; +const FINAL_STAGE = 6; + +export interface Promotion { fandomId: string; fromStage: number; toStage: number } + +/** + * 정식 행성 Stage 승급 판정 — 원장 **B-11 · B-53 · B-54**. + * + * - **누적 Growth POP(`growthPop`) 과 sFPI(`sfpi`) 를 동시에** 다음 Stage 진입값 + * (`planetStages/{n}.requiredPop` · `requiredSfpi`, 설계서 §2.3 누계) 이상이어야 오른다. + * 하나만 채우면 그대로 대기한다. + * - 진입값이 누계라 초과분은 자연히 다음 Stage 진행도로 이월된다(B-54 Carry-over). 그래서 + * 한 번의 판정에서 여러 Stage 를 채웠으면 **채운 가장 높은 Stage 까지** 올린다. + * - 강등하지 않는다(B-53 검증 기준 「Stage 강등 0건」). Stage6 이 마지막이다. + * - 다음 Stage 마스터 문서가 없거나 값이 비면 그 Stage 에서 멈춘다 — 빈 값을 0 으로 읽어 + * 승급시키지 않는다. + * + * 트랜잭션 안에서 다시 읽고 쓰므로 같은 날 두 번 돌아도 두 번째는 할 일이 없다(멱등). + * 기록(`stateHistory`)은 `fnPlanetEvolutionRecord` 가, 연출은 앱이 `stage` 상승을 보고 띄운다. + */ +export async function promoteStages(fandomIds?: string[]): Promise { + const db = getFirestore(); + const stageDocs = await db.collection('planetStages').get(); + const required = new Map(); + for (const doc of stageDocs.docs) { + const pop = doc.get('requiredPop'); + const sfpi = doc.get('requiredSfpi'); + if (Number.isFinite(pop) && Number.isFinite(sfpi)) required.set(Number(doc.id), { pop, sfpi }); + } + + const refs = fandomIds + ? fandomIds.map((id) => db.doc(`fandoms/${id}`)) + : (await db.collection('fandoms').where('stage', '>=', 1).select().get()).docs.map((d) => d.ref); + + const promoted: Promotion[] = []; + for (const ref of refs) { + const result = await db.runTransaction(async (tx) => { + const snap = await tx.get(ref); + const from = snap.get('stage'); + if (!Number.isInteger(from) || from < 1 || from >= FINAL_STAGE) return null; + const pop = Number(snap.get('growthPop') ?? 0); + const sfpi = Number(snap.get('sfpi') ?? 0); + let to = from as number; + for (let next = to + 1; next <= FINAL_STAGE; next++) { + const req = required.get(next); + if (!req || pop < req.pop || sfpi < req.sfpi) break; + to = next; + } + if (to === from) return null; + tx.update(ref, { stage: to, stageChangedAt: FieldValue.serverTimestamp() }); + return { fandomId: ref.id, fromStage: from as number, toStage: to, name: snap.get('name') as string | undefined }; + }); + if (!result) continue; + const { name, ...promotion } = result; + promoted.push(promotion); + // 원장 C-78 ③ 성취 · C-77 「Evolution」 메시지. 발송 실패는 승급을 되돌리지 않는다. + await sendFandomPush({ + fandomId: ref.id, + category: 'fandomEvent', + title: `${name ?? ref.id} 행성이 「${STAGE_NAMES[promotion.toStage]}」 단계로 진화했습니다`, + body: '당신도 이 진화에 함께했습니다. 행성에서 확인해 보세요.', + data: { kind: 'evolution', toStage: String(promotion.toStage) }, + }); + } + if (promoted.length > 0) logger.info('[evolution] Stage 승급', { promoted }); + return promoted; +} + +/** + * `fn-admin-planet-evolution-run` — 승급 판정을 지금 한 팬덤에 돌린다(master 전용). + * + * 판정은 매일 KST 자정 sFPI 누적 직후(`fnFpiDailySnapshot`)에 돈다. 실기기 확인 때 하루를 + * 기다리지 않도록 같은 판정을 즉시 부르는 경로다. 조건을 우회하지 않으며 결과(승급 없음 + * 포함)를 `adminActionLogs` 에 남긴다. + */ +export const fnAdminPlanetEvolutionRun = onCall(async (req) => { + const actorUid = callerUid(req); + const role = requireRole(req, ['master']); + const fandomId = str(req.data, 'fandomId'); + if (fandomId.includes('/')) fail(ERR.permissionDenied, '문서 경로가 아닙니다.'); + const [promotion] = await promoteStages([fandomId]); + await getFirestore().collection('adminActionLogs').add({ + actorUid, role, targetPath: `fandoms/${fandomId}`, reason: 'PLANET_EVOLUTION_RUN', + patch: promotion ? { fromStage: promotion.fromStage, stage: promotion.toStage } : { promoted: false }, + createdAt: FieldValue.serverTimestamp(), + }); + return { fandomId, promoted: promotion ?? null }; +}); diff --git a/app/firebase/functions/src/fandom/fandom.ts b/app/firebase/functions/src/fandom/fandom.ts index 9946076..96c75bc 100644 --- a/app/firebase/functions/src/fandom/fandom.ts +++ b/app/firebase/functions/src/fandom/fandom.ts @@ -13,6 +13,7 @@ import { rankWithTies } from '../ledger/fpi.js'; import { txIdOf } from '../core/ids.js'; import { dayKey } from '../core/time.js'; import { notifyEclipseWarning } from '../notify/push.js'; +import { promoteStages } from './evolution.js'; import { PUBLIC_NAME_FALLBACK, publicNameOf } from '../core/names.js'; /** @@ -842,7 +843,7 @@ export const fnFpiDailySnapshot = onSchedule( * 인정해 남긴 FPI 원천 이벤트(`fpiEvents`, verified · uid 있음) 1건 이상** — S01 확정 전 * 유지 기준. 활동 0명인 날도 `dau: 0` 으로 남긴다(C-27 「0명인 날 포함」). */ -export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number }> { +export async function closeDay(now: Date = new Date()): Promise<{ day: string; fandoms: number; promoted: number }> { const db = getFirestore(); const day = dayKey(new Date(now.getTime() - 24 * 60 * 60 * 1000)); const from = Timestamp.fromDate(new Date(`${day}T00:00:00+09:00`)); @@ -881,7 +882,9 @@ export async function closeDay(now: Date = new Date()): Promise<{ day: string; f tx.set(fandom.ref, { sfpi: FieldValue.increment(fpiScore) }, { merge: true }); }); } - return { day, fandoms: fandoms.size }; + // 원장 B-54 — sFPI 누적 직후 누적 Growth POP 과 함께 Stage 승급을 판정한다. + const promoted = await promoteStages(); + return { day, fandoms: fandoms.size, promoted: promoted.length }; } /** diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 4e92862..becf774 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -13,6 +13,7 @@ export { fnAuthOnCreate, fnNebulaCreate, fnPlanetCreate, fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnAdminSetRole, } from './account/account.js'; +export { fnAuthKakao } from './account/kakao.js'; export { fnEclipseDefend, fnEclipseAggregate, fnEclipseTrigger, fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot, @@ -49,5 +50,5 @@ export { } from './account/translation.js'; export { fnBriefingIngest, fnBriefingPublish } from './briefing/briefing.js'; -export { fnPlanetEvolutionRecord } from './fandom/evolution.js'; +export { fnPlanetEvolutionRecord, fnAdminPlanetEvolutionRun } from './fandom/evolution.js'; export { fnPlanetHistory } from './fandom/history.js'; diff --git a/app/firebase/functions/test/auth_kakao.test.ts b/app/firebase/functions/test/auth_kakao.test.ts new file mode 100644 index 0000000..4dcdeb7 --- /dev/null +++ b/app/firebase/functions/test/auth_kakao.test.ts @@ -0,0 +1,106 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAuth } from 'firebase-admin/auth'; + +import { bootAdmin, shutdownAdmin } from './support.js'; +import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js'; + +/** 카카오 커스텀 토큰 — 토큰 검증 · 기존 oidc.kakao 사용자 매핑 · 신규 UID · SNS 연동. */ + +const APP_ID = '123456'; +const KAKAO_ID = '987654321'; +const OLD_UID = 'test-kakao-oidc-user'; +const GOOGLE_UID = 'test-kakao-google-user'; + +type Req = Parameters[0]; +const call = (data: Record, uid?: string) => + fnAuthKakao.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req); +const codeOf = async (p: Promise) => { + try { await p; return 'ok'; } catch (e) { + const d = (e as { details?: { code?: string; reason?: string } }).details; + return d?.reason ?? d?.code; + } +}; + +/** 카카오 API 흉내. 토큰 'good' 만 우리 앱 것, 'other-app' 은 남의 앱 토큰이다. */ +function stubKakao(email?: string) { + vi.stubGlobal('fetch', vi.fn(async (url: string, init: { headers: Record }) => { + const token = init.headers.Authorization?.replace('Bearer ', ''); + if (token === 'expired') return new Response('{}', { status: 401 }); + if (url.endsWith('/v1/user/access_token_info')) { + return Response.json({ id: Number(KAKAO_ID), expires_in: 100, app_id: token === 'other-app' ? 1 : Number(APP_ID) }); + } + return Response.json({ + id: Number(KAKAO_ID), + kakao_account: { + profile: { nickname: '카카오팬' }, + ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}), + }, + }); + })); +} + +async function wipe() { + const uids = [OLD_UID, GOOGLE_UID, kakaoUidOf(KAKAO_ID)]; + await getAuth().deleteUsers(uids); +} + +beforeAll(() => { + if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) { + throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.'); + } + process.env.KAKAO_APP_ID = APP_ID; + bootAdmin(); +}); +afterAll(async () => { await wipe(); await shutdownAdmin(); }); +beforeEach(async () => { await wipe(); stubKakao(); }); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('토큰 검증', () => { + it('만료·무효 토큰과 남의 앱 토큰은 E-AUTH-03', async () => { + expect(await codeOf(call({ accessToken: 'expired' }))).toBe('E-AUTH-03'); + expect(await codeOf(call({ accessToken: 'other-app' }))).toBe('E-AUTH-03'); + }); +}); + +describe('로그인', () => { + it('OIDC 시절 oidc.kakao 가입자는 같은 UID 로 들어온다', async () => { + await getAuth().createUser({ uid: OLD_UID }); + await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: KAKAO_ID } }); + const { token } = await call({ accessToken: 'good' }) as { token: string }; + expect(JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid).toBe(OLD_UID); + }); + + it('신규는 kakao:<회원번호> 로 만들고 oidc.kakao 를 붙인다 · 두 번째도 같은 UID', async () => { + const first = await call({ accessToken: 'good' }) as { token: string }; + await call({ accessToken: 'good' }); + const uid = JSON.parse(Buffer.from(first.token.split('.')[1]!, 'base64url').toString()).uid; + expect(uid).toBe(kakaoUidOf(KAKAO_ID)); + const user = await getAuth().getUser(uid); + expect(user.displayName).toBe('카카오팬'); + expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[KAKAO_PROVIDER_ID, KAKAO_ID]]); + }); +}); + +describe('SNS 연동', () => { + it('로그인 없이 link 는 E-AUTH-03', async () => { + expect(await codeOf(call({ accessToken: 'good', link: true }))).toBe('E-AUTH-03'); + }); + + it('Google 사용자에 붙인다 · 다른 UID 가 쓰는 카카오는 E-AUTH-08', async () => { + await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); + expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + const user = await getAuth().getUser(GOOGLE_UID); + expect(user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID && d.uid === KAKAO_ID)).toBe(true); + + await getAuth().createUser({ uid: OLD_UID }); + expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08'); + }); + + it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => { + await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); + stubKakao('other@kakao.com'); + expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('different-email'); + stubKakao('FAN@fanuzu.co.kr'); + expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + }); +}); diff --git a/app/firebase/functions/test/stage_promotion.test.ts b/app/firebase/functions/test/stage_promotion.test.ts new file mode 100644 index 0000000..f6c0e09 --- /dev/null +++ b/app/firebase/functions/test/stage_promotion.test.ts @@ -0,0 +1,83 @@ +import { afterAll, beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import { getFirestore } from 'firebase-admin/firestore'; +import { bootAdmin, shutdownAdmin } from './support.js'; + +const push = vi.hoisted(() => vi.fn(async () => true)); +vi.mock('../src/notify/push.js', () => ({ sendFandomPush: push, notifyEclipseWarning: vi.fn() })); + +const { fnAdminPlanetEvolutionRun, promoteStages } = await import('../src/fandom/evolution.js'); +const { closeDay } = await import('../src/fandom/fandom.js'); + +/** 설계서 §2.3 누계 — 발주사 수치표 v1.4 「Stage Summary」(seed-demo.py 와 같은 표). */ +const STAGES: Array<[number, number, number]> = [ + [1, 0, 0], [2, 100_000, 15_000], [3, 500_000, 75_000], [4, 2_000_000, 300_000], + [5, 10_000_000, 1_500_000], [6, 50_000_000, 7_500_000], +]; +const id = 'test-stage-promotion'; +const db = () => getFirestore(); +const stage = async () => (await db().doc(`fandoms/${id}`).get()).get('stage'); +const set = (data: Record) => db().doc(`fandoms/${id}`).set({ name: '승급', ...data }); + +beforeAll(async () => { + bootAdmin(); + for (const [n, pop, sfpi] of STAGES) { + await db().doc(`planetStages/${n}`).set({ stageNo: n, requiredPop: pop, requiredSfpi: sfpi }); + } +}); +beforeEach(async () => { + push.mockClear(); + await db().recursiveDelete(db().doc(`fandoms/${id}`)); +}); +afterAll(async () => { + await db().recursiveDelete(db().doc(`fandoms/${id}`)); + await shutdownAdmin(); +}); + +it('조건 미달 · 한쪽만 충족이면 대기한다', async () => { + for (const [pop, sfpi] of [[99_999, 14_999], [5_000_000, 14_999], [99_999, 9_000_000]]) { + await set({ stage: 1, growthPop: pop, sfpi }); + expect(await promoteStages([id])).toEqual([]); + expect(await stage()).toBe(1); + } + expect(push).not.toHaveBeenCalled(); +}); + +it('둘 다 충족하면 오르고, 두 번 돌아도 한 번만 오른다', async () => { + await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 }); + await Promise.all([promoteStages([id]), promoteStages([id])]); + await promoteStages([id]); + expect(await stage()).toBe(2); + expect(push).toHaveBeenCalledTimes(1); +}); + +it('여러 Stage 를 채웠으면 동시 충족한 가장 높은 Stage 까지 · 성운 · 최종 단계는 건드리지 않는다', async () => { + // POP 은 Stage5 까지, sFPI 는 Stage3 까지 → Stage3. + await set({ stage: 1, growthPop: 10_000_000, sfpi: 100_000 }); + expect(await promoteStages([id])).toEqual([{ fandomId: id, fromStage: 1, toStage: 3 }]); + await set({ stage: 6, growthPop: 1e12, sfpi: 1e12 }); + expect(await promoteStages([id])).toEqual([]); + await set({ stage: 0, growthPop: 1e12, sfpi: 1e12 }); + expect(await promoteStages([id])).toEqual([]); + expect(await stage()).toBe(0); +}); + +it('일일 마감(sFPI 누적) 직후 판정한다 — 그날 누적으로 문턱을 넘으면 오른다', async () => { + await set({ stage: 1, growthPop: 100_000, sfpi: 14_000, fpiScore: 1_000 }); + await closeDay(new Date('2030-01-02T00:00:30+09:00')); + expect(await stage()).toBe(2); + await closeDay(new Date('2030-01-02T00:00:30+09:00')); + expect((await db().doc(`fandoms/${id}`).get()).get('sfpi')).toBe(15_000); + expect(push).toHaveBeenCalledTimes(1); +}); + +it('운영자 즉시 실행은 master 만 · 결과를 adminActionLogs 에 남긴다', async () => { + await set({ stage: 1, growthPop: 100_000, sfpi: 15_000 }); + const run = (role: string) => fnAdminPlanetEvolutionRun.run({ + data: { fandomId: id }, auth: { uid: `evo-${role}`, token: { role } }, + } as unknown as Parameters[0]); + await expect(run('ops')).rejects.toMatchObject({ details: { code: 'E-PERM-01' } }); + expect(await stage()).toBe(1); + expect((await run('master')).promoted).toEqual({ fandomId: id, fromStage: 1, toStage: 2 }); + const logs = await db().collection('adminActionLogs').where('actorUid', '==', 'evo-master').get(); + expect(logs.docs.some((d) => d.get('reason') === 'PLANET_EVOLUTION_RUN' && d.get('patch.stage') === 2)).toBe(true); +}); diff --git a/app/firebase/package.json b/app/firebase/package.json index 6695919..8fe849b 100644 --- a/app/firebase/package.json +++ b/app/firebase/package.json @@ -6,7 +6,7 @@ "scripts": { "test": "npm run test:rules && npm run test:functions", "test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"", - "test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\"" + "test:functions": "firebase emulators:exec --only firestore,auth --project demo-fanuzu \"npm --prefix functions test\"" }, "devDependencies": { "firebase-tools": "15.28.2" diff --git a/app/ios/Flutter/Debug.xcconfig b/app/ios/Flutter/Debug.xcconfig index c858fd2..09d9b71 100644 --- a/app/ios/Flutter/Debug.xcconfig +++ b/app/ios/Flutter/Debug.xcconfig @@ -5,3 +5,7 @@ // `#include?` 는 파일이 없어도 빌드를 멈추지 않는다. 설정 파일 투입 전에도 // 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다. #include? "../config/Google.xcconfig" + +// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴. +// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다. +#include? "../../config/kakao.env" diff --git a/app/ios/Flutter/Release.xcconfig b/app/ios/Flutter/Release.xcconfig index f1c1184..554a12a 100644 --- a/app/ios/Flutter/Release.xcconfig +++ b/app/ios/Flutter/Release.xcconfig @@ -5,3 +5,7 @@ // `#include?` 는 파일이 없어도 빌드를 멈추지 않는다. 설정 파일 투입 전에도 // 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다. #include? "../config/Google.xcconfig" + +// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴. +// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다. +#include? "../../config/kakao.env" diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 09b578d..89b16a7 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -287,6 +287,22 @@ $(GOOGLE_REVERSED_CLIENT_ID) + + + CFBundleTypeRole + Editor + CFBundleURLName + kakao-login + CFBundleURLSchemes + + kakao$(FANUZU_KAKAO_NATIVE_APP_KEY) + + + + + LSApplicationQueriesSchemes + + kakaokompassauth UIApplicationSceneManifest diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 09d299b..7e13b60 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -57,25 +57,25 @@ class AuthRepository extends ChangeNotifier { /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. /// QA 경로는 [qaEmailProvider] 하나뿐이다. /// - /// 카카오·LINE 은 Firebase OIDC 제공자다. 콘솔 설정 전이면 Firebase 가 - /// `operation-not-allowed`(E-AUTH-11)를 돌려준다. + /// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC + /// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를 + /// 돌려준다. static List _defaultProviders() => [ const GoogleSignInProvider(), AppleSignInProvider( webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), ), - // 동의 항목: 카카오 콘솔의 닉네임·이메일, LINE 의 profile·email(이메일 권한 신청 승인 후). - OidcSignInProvider( - AuthProviderKind.kakao, - providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.kakao]!, - // 이메일(account_email)은 비즈 앱 전환 후에만 요청할 수 있다. 전환 전에 요청하면 - // KOE205 로 로그인 자체가 막힌다 — 전환되면 여기에 다시 넣는다. - scopes: const ['profile_nickname'], - ), + // 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱 + // 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다. + KakaoSignInProvider(), + // LINE 동의 항목: profile·email(이메일 권한 신청 승인 후). OidcSignInProvider( AuthProviderKind.line, providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!, scopes: const ['profile', 'email'], + // LINE 앱 자동 로그인으로 넘어가면 콜백이 Custom Tab 밖에서 열려 로그인이 + // 깨진다(Android). LINE Login 공식 파라미터로 끈다. + androidParameters: const {'disable_auto_login': 'true'}, ), ]; diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index 163accc..5d8e6bd 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -27,7 +27,8 @@ final class FirebaseAccountLinker implements AccountLinker { AuthProviderKind.google: 'google.com', AuthProviderKind.apple: 'apple.com', // 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다. - // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. + // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을 + // 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다. AuthProviderKind.kakao: 'oidc.kakao', AuthProviderKind.line: 'oidc.line', AuthProviderKind.email: 'password', @@ -56,11 +57,14 @@ final class FirebaseAccountLinker implements AccountLinker { } final missing = impl.missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); - if (impl is! CredentialProvider && impl is! OidcSignInProvider) { + if (impl is! CredentialProvider && + impl is! OidcSignInProvider && + impl is! KakaoSignInProvider) { return AuthNotConfigured(provider, const ['자격증명 구현']); } try { + if (impl is KakaoSignInProvider) return await impl.link(user); if (impl is OidcSignInProvider) return await _linkOidc(user, impl); final source = impl as CredentialProvider; final obtained = await source.obtainCredential(); @@ -130,7 +134,9 @@ final class FirebaseAccountLinker implements AccountLinker { /// blocking 함수(beforeSignIn)로 서버에서 거른다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { final mine = user.email; - final result = await user.linkWithProvider(impl.authProvider); + final result = await untilAbandoned( + user.linkWithProvider(impl.authProvider), + ); // 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때). final theirs = _emailOf(result.user, impl.providerId); if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) { diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 52c9b27..dcddc05 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -1,11 +1,17 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:cloud_functions/cloud_functions.dart'; import 'package:crypto/crypto.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter/widgets.dart'; import 'package:google_sign_in/google_sign_in.dart' as gsi; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao; import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple; +import '../../core/data/backend.dart'; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; import '../../core/i18n/app_locale.dart'; @@ -199,7 +205,8 @@ final class AppleSignInProvider kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// 카카오·LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.kakao` · `oidc.line`). +/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나 +/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다). /// /// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을 /// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이 @@ -219,6 +226,7 @@ final class OidcSignInProvider implements SocialAuthProvider { this.provider, { required this.providerId, this.scopes = const [], + this.androidParameters = const {}, this.firebaseAuth, }); @@ -233,6 +241,12 @@ final class OidcSignInProvider implements SocialAuthProvider { /// KOE205 로 로그인 자체를 막는다. final List scopes; + /// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로 + /// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가 + /// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다. + /// iOS 는 정상이라 건드리지 않는다. + final Map androidParameters; + /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. final fb.FirebaseAuth? firebaseAuth; @@ -248,6 +262,9 @@ final class OidcSignInProvider implements SocialAuthProvider { for (final scope in scopes) { p.addScope(scope); } + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + p.setCustomParameters(androidParameters); + } return p; } @@ -256,8 +273,11 @@ final class OidcSignInProvider implements SocialAuthProvider { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { - final result = await (firebaseAuth ?? fb.FirebaseAuth.instance) - .signInWithProvider(authProvider); + final result = await untilAbandoned( + (firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider( + authProvider, + ), + ); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { if (isWebFlowCancelled(error)) return const AuthCancelled(); @@ -275,11 +295,190 @@ final class OidcSignInProvider implements SocialAuthProvider { } } +/// 카카오 네이티브 앱 키. `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다 +/// (같은 파일을 Android manifestPlaceholders · iOS xcconfig 가 읽는다). +const kakaoNativeAppKey = String.fromEnvironment('FANUZU_KAKAO_NATIVE_APP_KEY'); + +/// 카카오 — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthKakao`). iOS·Android 같은 경로다. +/// +/// Firebase OIDC(`oidc.kakao`) 웹 흐름은 Android 에서 「카카오톡으로 로그인」을 고르면 +/// 콜백이 Custom Tab 밖(일반 Chrome)에서 열려 「missing initial state」로 깨진다. 카카오에는 +/// 앱 전환을 막는 파라미터가 없어서, SDK 가 카카오톡(없으면 카카오계정)으로 로그인해 받은 +/// 액세스 토큰을 서버가 카카오 API 로 검증하고 커스텀 토큰을 내준다. +/// +/// 서버는 사용자 제공자 정보에 `oidc.kakao` 를 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 +/// 들어오고, [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다. +final class KakaoSignInProvider implements SocialAuthProvider { + KakaoSignInProvider({ + Future Function()? accessToken, + Future> Function(Map payload)? + exchange, + this.firebaseAuth, + this.nativeAppKey = kakaoNativeAppKey, + }) : _accessToken = accessToken ?? _sdkAccessToken, + _exchange = exchange ?? _callServer; + + final Future Function() _accessToken; + final Future> Function(Map) _exchange; + + /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. + final fb.FirebaseAuth? firebaseAuth; + final String nativeAppKey; + + static Future? _init; + + @override + SocialProvider get provider => SocialProvider.kakao; + + @override + bool get isLive => true; + + @override + List get missingConfig => [ + if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels, + if (nativeAppKey.isEmpty) '카카오 네이티브 앱 키', + ]; + + fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance; + + /// 카카오톡이 있으면 카카오톡, 없거나 실패하면 카카오계정(공식 가이드 순서). + /// 사용자 취소(`CANCELED`)는 계정 로그인으로 넘기지 않고 그대로 던진다. + static Future _sdkAccessToken() async { + await (_init ??= kakao.KakaoSdk.init(nativeAppKey: kakaoNativeAppKey)); + final api = kakao.UserApi.instance; + if (await kakao.isKakaoTalkInstalled()) { + try { + return (await api.loginWithKakaoTalk()).accessToken; + } on PlatformException catch (error) { + if (error.code == 'CANCELED') rethrow; + } + } + return (await api.loginWithKakaoAccount()).accessToken; + } + + static Future> _callServer( + Map payload, + ) async { + final result = await FirebaseFunctions.instanceFor( + region: Backend.region, + ).httpsCallable('fnAuthKakao').call(payload); + final data = result.data; + return data is Map ? Map.from(data) : const {}; + } + + @override + Future signIn() => _run(AuthFlow.signIn, () async { + final data = await _exchange({'accessToken': await _accessToken()}); + final result = await _auth.signInWithCustomToken(data['token'] as String); + return AuthSucceeded(_toAccount(result.user, provider)); + }); + + /// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다. + Future link(fb.User user) => _run(AuthFlow.link, () async { + await _exchange({'accessToken': await _accessToken(), 'link': true}); + await user.reload(); + return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider)); + }); + + Future _run( + AuthFlow flow, + Future Function() body, + ) async { + final missing = missingConfig; + if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); + try { + return await body(); + } on PlatformException catch (error) { + if (error.code == 'CANCELED') return const AuthCancelled(); + debugPrint('[KakaoSignInProvider] sdk failed: ${error.code}'); + return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); + } on kakao.KakaoAuthException catch (error) { + // 동의 화면에서 「취소」. + if (error.error == kakao.AuthErrorCause.accessDenied) { + return const AuthCancelled(); + } + return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); + } on FirebaseFunctionsException catch (error) { + final details = error.details; + final code = details is Map && details['code'] is String + ? details['code'] as String + : 'E-AUTH-03'; + return AuthFailed(code, switch (code) { + _ when details is Map && details['reason'] == 'different-email' => + appL10n.authDifferentEmail, + 'E-AUTH-08' => appL10n.authCredentialInUse, + 'E-AUTH-09' => appL10n.authAlreadyLinked, + _ => + flow == AuthFlow.link + ? appL10n.authLinkFailed + : appL10n.authSocialFailed, + }); + } on fb.FirebaseAuthException catch (error) { + return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow)); + } + } + + @override + Future signOut() async { + // SDK 를 한 번도 띄우지 않았으면 지울 카카오 토큰도 없다. + if (_init == null) return; + try { + await kakao.UserApi.instance.logout(); + } on Object catch (error) { + // 토큰이 이미 만료·폐기됐어도 SDK 는 로컬 토큰을 지운다. 로그아웃을 막을 이유가 없다. + debugPrint('[KakaoSignInProvider] logout: ${error.runtimeType}'); + } + } +} + /// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. bool isWebFlowCancelled(fb.FirebaseAuthException error) => error.code == 'web-context-canceled' || error.code == 'web-context-cancelled'; +/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다. +/// +/// 콜백이 Custom Tab 밖(일반 Chrome)에서 열리면 Firebase 의 대기 Task 가 영영 +/// 끝나지 않아 로그인 버튼이 잠긴다. 앱이 포그라운드로 돌아온 뒤 [grace] 안에 +/// 결과가 없으면 사용자가 흐름을 버린 것으로 보고 기존 취소 경로([isWebFlowCancelled])로 +/// 넘긴다. 정상 완료는 돌아온 직후 토큰 교환 한 번이라 [grace] 안에 끝난다. +/// iOS 는 SFSafariViewController 가 취소를 스스로 알려 주므로 그대로 둔다. +/// +/// ponytail: 고정 대기 시간. 망이 아주 느려 [grace] 를 넘기면 취소로 보이지만 +/// Firebase 세션은 나중에 열리므로 authStateChanges 가 따라잡는다. +Future untilAbandoned( + Future flow, { + Duration grace = const Duration(seconds: 8), +}) { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return flow; + final done = Completer(); + Timer? timer; + final listener = AppLifecycleListener( + onStateChange: (state) { + timer?.cancel(); + if (state != AppLifecycleState.resumed) return; + timer = Timer(grace, () { + if (done.isCompleted) return; + done.completeError( + fb.FirebaseAuthException(code: 'web-context-canceled'), + ); + }); + }, + ); + flow.then( + (value) { + if (!done.isCompleted) done.complete(value); + }, + onError: (Object error, StackTrace stack) { + if (!done.isCompleted) done.completeError(error, stack); + }, + ); + return done.future.whenComplete(() { + timer?.cancel(); + listener.dispose(); + }); +} + /// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다. /// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다. apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() { diff --git a/app/pubspec.lock b/app/pubspec.lock index ca34ade..4ef4962 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -9,6 +9,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.76" + adaptive_number: + dependency: transitive + description: + name: adaptive_number + sha256: "3a567544e9b5c9c803006f51140ad544aedc79604fd4f3f2c1380003f97c1d77" + url: "https://pub.dev" + source: hosted + version: "1.0.0" antlr4: dependency: transitive description: @@ -33,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.7.0" + asn1lib: + dependency: transitive + description: + name: asn1lib + sha256: "9a8f69025044eb466b9b60ef3bc3ac99b4dc6c158ae9c56d25eeccf5bc56d024" + url: "https://pub.dev" + source: hosted + version: "1.6.5" async: dependency: transitive description: @@ -178,13 +194,45 @@ packages: source: hosted version: "3.0.7" dart_jsonwebtoken: - dependency: transitive + dependency: "direct overridden" description: name: dart_jsonwebtoken - sha256: ad84e60181696513d04d5f2078e0bbc20365b911f46f647797317414bdc88fbe + sha256: "00a0812d2aeaeb0d30bcbc4dd3cee57971dbc0ab2216adf4f0247f37793f15ef" url: "https://pub.dev" source: hosted - version: "3.4.1" + version: "2.17.0" + dio: + dependency: transitive + description: + name: dio + sha256: "852ec3b48cc431ac04fff978413c541502b67ffc3e26921e74e3d994694192c1" + url: "https://pub.dev" + source: hosted + version: "5.11.1" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "3a1b2cd7be71086f38504956e3ebcd2837288d231ff454bafa78021244102bfc" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + ed25519_edwards: + dependency: transitive + description: + name: ed25519_edwards + sha256: f3c3b109a0e470ee35f6346a155cab290578660852237fce10df6bbd92758534 + url: "https://pub.dev" + source: hosted + version: "0.3.2" + encrypt: + dependency: transitive + description: + name: encrypt + sha256: "62d9aa4670cc2a8798bab89b39fc71b6dfbacf615de6cf5001fb39f7e4a996a2" + url: "https://pub.dev" + source: hosted + version: "5.0.3" equatable: dependency: transitive description: @@ -416,6 +464,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.35" + flutter_svg: + dependency: transitive + description: + name: flutter_svg + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + url: "https://pub.dev" + source: hosted + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -708,6 +764,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" json_annotation: dependency: transitive description: @@ -716,6 +780,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.12.0" + kakao_flutter_sdk_auth: + dependency: transitive + description: + name: kakao_flutter_sdk_auth + sha256: b9d982569462f6d31f97460f1d6f51acdc290f318967ee14009d20edc3006a12 + url: "https://pub.dev" + source: hosted + version: "2.0.1" + kakao_flutter_sdk_common: + dependency: transitive + description: + name: kakao_flutter_sdk_common + sha256: "6a89e80ef6c080f434b05df87d9dd83323ff6d10ea34d9e0a6cd90d2015f7e48" + url: "https://pub.dev" + source: hosted + version: "2.0.1" + kakao_flutter_sdk_user: + dependency: "direct main" + description: + name: kakao_flutter_sdk_user + sha256: "047ff5a82f52a7e1558086acd87b89b941b072e6029de3eee0b96dbce4ca1e48" + url: "https://pub.dev" + source: hosted + version: "2.0.1" leak_tracker: dependency: transitive description: @@ -836,6 +924,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.1" + path_parsing: + dependency: transitive + description: + name: path_parsing + sha256: "883402936929eac138ee0a45da5b0f2c80f89913e6dc3bf77eb65b84b409c6ca" + url: "https://pub.dev" + source: hosted + version: "1.1.0" path_provider: dependency: transitive description: @@ -968,10 +1064,10 @@ packages: dependency: transitive description: name: pointycastle - sha256: "92aa3841d083cc4b0f4709b5c74fd6409a3e6ba833ffc7dc6a8fee096366acf5" + sha256: "4be0097fcf3fd3e8449e53730c631200ebc7b88016acecab2b0da2f0149222fe" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "3.9.1" posix: dependency: transitive description: @@ -1273,6 +1369,30 @@ packages: url: "https://pub.dev" source: hosted version: "4.6.0" + vector_graphics: + dependency: transitive + description: + name: vector_graphics + sha256: "9d0e3b9cb16542ad660daee871e726a10d13a93b7b5391677c3160e8f5e83935" + url: "https://pub.dev" + source: hosted + version: "1.2.3" + vector_graphics_codec: + dependency: transitive + description: + name: vector_graphics_codec + sha256: "99fd9fbd34d9f9a32efd7b6a6aae14125d8237b10403b422a6a6dfeac2806146" + url: "https://pub.dev" + source: hosted + version: "1.1.13" + vector_graphics_compiler: + dependency: transitive + description: + name: vector_graphics_compiler + sha256: "4dca4feb77dc3ec7f6e27e49c53241eb8217f55e4f9b12599a27f8903bca5682" + url: "https://pub.dev" + source: hosted + version: "1.3.0" vector_math: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bcb03c8..a8b2be4 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -107,6 +107,11 @@ dependencies: # 2026-10-05 대표 지시 「html 을 클론코딩하듯」). flutter.dev 팀 관리. # 최소 OS 하한 영향 없음: webview_flutter_android minSdk 24(앱 29) · wkwebview iOS 13.0(앱 15.0). webview_flutter: ^4.14.1 + # 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해 + # 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서 + # Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다. + # 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0). + kakao_flutter_sdk_user: ^2.0.1 dev_dependencies: flutter_test: @@ -124,6 +129,12 @@ dev_dependencies: # 재생성: dart run flutter_launcher_icons flutter_launcher_icons: ^0.14.4 +# 카카오 SDK(encrypt → pointycastle 3)와 firebase_auth_mocks 0.15(dart_jsonwebtoken 3 → +# pointycastle 4)가 갈린다. 테스트 전용인 쪽을 내린다 — 앱에 실리는 암호 라이브러리는 그대로 둔다. +# 둘 중 하나가 pointycastle 범위를 맞추면 지운다. +dependency_overrides: + dart_jsonwebtoken: ^2.17.0 + # 앱 아이콘 — 2026-08-13 수령한 공식 `fanuzu logo only.png`의 중앙 1:1 영역을 # 픽셀 변경 없이 사용한다. `assets/brand/` 두 원본은 같은 공식 마크다. flutter_launcher_icons: diff --git a/app/test/auth_kakao_test.dart b/app/test/auth_kakao_test.dart new file mode 100644 index 0000000..6af6143 --- /dev/null +++ b/app/test/auth_kakao_test.dart @@ -0,0 +1,109 @@ +import 'package:cloud_functions/cloud_functions.dart'; +import 'package:fancake/core/firebase/firebase_boot.dart'; +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/firebase_account_linker.dart'; +import 'package:fancake/features/auth/social_providers.dart'; +import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 카카오 — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약. +/// 토큰 검증·기존 oidc.kakao 사용자 매핑은 서버 테스트(functions/test/auth_kakao.test.ts)가 본다. +void main() { + setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: '카카오 테스트')); + tearDown( + () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '카카오 테스트 끝'), + ); + + final sent = >[]; + setUp(sent.clear); + + KakaoSignInProvider kakao( + MockFirebaseAuth auth, { + Future Function()? token, + Object? serverError, + String appKey = 'test-key', + }) => KakaoSignInProvider( + accessToken: token ?? () async => 'kakao-access', + exchange: (payload) async { + sent.add(payload); + if (serverError != null) throw serverError; + return { + 'token': 'custom-token', + if (payload['link'] == true) 'linked': true, + }; + }, + firebaseAuth: auth, + nativeAppKey: appKey, + ); + + test('성공 — 카카오 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async { + final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'kakao:1')); + final outcome = await kakao(auth).signIn(); + expect(sent.single, {'accessToken': 'kakao-access'}); + expect((outcome as AuthSucceeded).account.uid, 'kakao:1'); + expect(outcome.account.provider, AuthProviderKind.kakao); + expect(auth.currentUser?.uid, 'kakao:1'); + }); + + test('앱 키가 없으면 SDK·서버를 부르지 않고 미설정', () async { + final outcome = await kakao(MockFirebaseAuth(), appKey: '').signIn(); + expect(outcome, isA()); + expect(sent, isEmpty); + }); + + test('카카오 로그인 취소(CANCELED)는 취소 · 서버를 부르지 않는다', () async { + final outcome = await kakao( + MockFirebaseAuth(), + token: () async => throw PlatformException(code: 'CANCELED'), + ).signIn(); + expect(outcome, isA()); + expect(sent, isEmpty); + }); + + test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async { + final auth = MockFirebaseAuth(); + final outcome = await kakao( + auth, + serverError: FirebaseFunctionsException( + code: 'permission-denied', + message: '카카오 인증을 확인하지 못했습니다.', + details: {'code': 'E-AUTH-03'}, + ), + ).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(auth.currentUser, isNull); + }); + + group('SNS 연동', () { + test('로그인한 사용자에 link: true 로 붙인다', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + final linker = FirebaseAccountLinker( + providers: [kakao(auth)], + firebaseAuth: auth, + ); + final outcome = await linker.link(AuthProviderKind.kakao); + expect(sent.single, {'accessToken': 'kakao-access', 'link': true}); + expect((outcome as AuthSucceeded).account.uid, 'u-1'); + }); + + test('다른 이메일이면 E-AUTH-08 · 이메일 안내 문구', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + final outcome = await kakao( + auth, + serverError: FirebaseFunctionsException( + code: 'already-exists', + message: 'x', + details: {'code': 'E-AUTH-08', 'reason': 'different-email'}, + ), + ).link(auth.currentUser!); + expect((outcome as AuthFailed).code, 'E-AUTH-08'); + }); + }); +} diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index 55553b2..6c4fc23 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; @@ -5,95 +7,122 @@ import 'package:fancake/features/auth/firebase_account_linker.dart'; import 'package:fancake/features/auth/social_providers.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:firebase_auth_mocks/firebase_auth_mocks.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -/// 카카오·LINE OIDC 제공자 — 콘솔 설정 전이라 실 로그인은 못 하고, +/// LINE OIDC 제공자 — 실 로그인은 못 하고, /// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다. void main() { + // Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned). + TestWidgetsFlutterBinding.ensureInitialized(); setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트')); tearDown( () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'), ); - OidcSignInProvider kakao(fb.FirebaseAuth auth) => OidcSignInProvider( - AuthProviderKind.kakao, - providerId: 'oidc.kakao', + OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider( + AuthProviderKind.line, + providerId: 'oidc.line', firebaseAuth: auth, ); - test('기본 목록의 카카오·LINE 은 Firebase OIDC 제공자다 — 「준비 중」이 아니다', () { + test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () { final providers = { for (final p in AuthRepository().providers) p.provider: p, }; - for (final kind in [AuthProviderKind.kakao, AuthProviderKind.line]) { - final p = providers[kind]; - expect(p, isA(), reason: kind.name); - expect(p!.isLive, isTrue); - expect( - (p as OidcSignInProvider).providerId, - FirebaseAccountLinker.providerIds[kind], - ); - expect(p.authProvider.providerId, 'oidc.${kind.name}'); - } + final p = providers[AuthProviderKind.line]; + expect(p, isA()); + expect(p!.isLive, isTrue); + expect( + (p as OidcSignInProvider).providerId, + FirebaseAccountLinker.providerIds[AuthProviderKind.line], + ); + expect(p.authProvider.providerId, 'oidc.line'); + expect(providers[AuthProviderKind.kakao], isA()); }); test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async { FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음'); - final outcome = await kakao(_ThrowingAuth('should-not-call')).signIn(); + final outcome = await line(_ThrowingAuth('should-not-call')).signIn(); expect(outcome, isA()); }); - test('성공하면 카카오 계정으로 로그인된다', () async { + test('성공하면 LINE 계정으로 로그인된다', () async { final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1')); - final outcome = await kakao(auth).signIn(); + final outcome = await line(auth).signIn(); expect(outcome, isA()); - expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.kakao); + expect((outcome as AuthSucceeded).account.provider, AuthProviderKind.line); expect(outcome.account.uid, 'k-1'); }); test('인가 화면을 닫으면 Android·iOS 철자 모두 취소로 본다', () async { for (final code in ['web-context-canceled', 'web-context-cancelled']) { - final outcome = await kakao(_ThrowingAuth(code)).signIn(); + final outcome = await line(_ThrowingAuth(code)).signIn(); expect(outcome, isA(), reason: code); } }); test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async { - final outcome = await kakao( - _ThrowingAuth('operation-not-allowed'), - ).signIn(); + final outcome = await line(_ThrowingAuth('operation-not-allowed')).signIn(); expect((outcome as AuthFailed).code, 'E-AUTH-11'); }); + test('Android 의 LINE 은 앱 자동 로그인을 끈다 · iOS 는 파라미터 없음', () { + final line = + AuthRepository().providers.firstWhere( + (p) => p.provider == AuthProviderKind.line, + ) + as OidcSignInProvider; + expect(line.authProvider.parameters, {'disable_auto_login': 'true'}); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + expect(line.authProvider.parameters, isEmpty); + }); + + testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async { + late AuthOutcome outcome; + line(_HangingAuth()).signIn().then((o) => outcome = o); + void go(List states) => + states.forEach(tester.binding.handleAppLifecycleStateChanged); + const inactive = AppLifecycleState.inactive; + const hidden = AppLifecycleState.hidden; + go([inactive, hidden, AppLifecycleState.paused]); // Custom Tab 으로 나감 + await tester.pump(const Duration(minutes: 1)); // 브라우저에 오래 있어도 그대로 + go([hidden, inactive, AppLifecycleState.resumed]); // 앱으로 돌아옴 + await tester.pump(const Duration(seconds: 9)); + expect(outcome, isA()); + }); + group('계정 연결', () { - test('같은 UID 에 oidc.kakao 를 붙인다', () async { + test('같은 UID 에 oidc.line 을 붙인다', () async { final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final linker = FirebaseAccountLinker( - providers: [kakao(auth)], + providers: [line(auth)], firebaseAuth: auth, ); - final outcome = await linker.link(AuthProviderKind.kakao); + final outcome = await linker.link(AuthProviderKind.line); expect(outcome, isA()); - expect(linker.linkedAccounts.keys, contains(AuthProviderKind.kakao)); + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); }); - test('다른 이메일의 카카오 계정은 붙였다가 바로 떼고 E-AUTH-08', () async { + test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async { final user = _LinkingUser(linkedEmail: 'someone@else.com'); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final linker = FirebaseAccountLinker( - providers: [kakao(auth)], + providers: [line(auth)], firebaseAuth: auth, ); - final outcome = await linker.link(AuthProviderKind.kakao); + final outcome = await linker.link(AuthProviderKind.line); expect((outcome as AuthFailed).code, 'E-AUTH-08'); expect( linker.linkedAccounts.keys, - isNot(contains(AuthProviderKind.kakao)), + isNot(contains(AuthProviderKind.line)), ); }); }); @@ -108,6 +137,13 @@ class _ThrowingAuth extends MockFirebaseAuth { throw fb.FirebaseAuthException(code: code); } +/// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task. +class _HangingAuth extends MockFirebaseAuth { + @override + Future signInWithProvider(fb.AuthProvider provider) => + Completer().future; +} + /// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다. // ignore: must_be_immutable class _LinkingUser extends MockUser {