From c301f3a1db7253c8498be6afb2c7f041e7618d62 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 19:01:28 +0900 Subject: [PATCH 01/19] =?UTF-8?q?Kakao=20=EB=A1=9C=EA=B7=B8=EC=9D=B8(?= =?UTF-8?q?=EC=B9=B4=EC=B9=B4=EC=98=A4=20SDK=20=C2=B7=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=20=EC=BB=A4=EC=8A=A4=ED=85=80=20=ED=86=A0=ED=81=B0)=EA=B3=BC?= =?UTF-8?q?=20LINE=20=EB=A1=9C=EA=B7=B8=EC=9D=B8(Firebase=20OIDC)=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=C2=B7=20SNS=20=EC=97=B0=EB=8F=99=20=C2=B7?= =?UTF-8?q?=20=EC=9D=B8=EC=A6=9D=20=EC=B7=A8=EC=86=8C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.gitignore | 3 + app/BUILD.md | 19 ++ app/README.md | 12 +- app/android/app/build.gradle.kts | 11 + app/android/app/src/main/AndroidManifest.xml | 12 + app/config/kakao.env.example | 1 + app/firebase/functions/package.json | 2 +- app/firebase/functions/src/account/kakao.ts | 143 +++++++++ app/firebase/functions/src/core/errors.ts | 13 +- app/firebase/functions/src/index.ts | 1 + .../functions/test/auth_kakao.test.ts | 106 +++++++ app/firebase/package.json | 2 +- app/ios/Flutter/Debug.xcconfig | 4 + app/ios/Flutter/Release.xcconfig | 4 + app/ios/Runner/Info.plist | 16 + app/lib/features/auth/auth.dart | 4 +- app/lib/features/auth/auth_repository.dart | 23 +- .../auth/firebase_account_linker.dart | 40 ++- app/lib/features/auth/social_providers.dart | 287 ++++++++++++++++-- app/pubspec.lock | 230 +++++++++++++- app/pubspec.yaml | 13 +- app/test/auth_kakao_test.dart | 109 +++++++ app/test/auth_oidc_test.dart | 177 +++++++++++ 23 files changed, 1179 insertions(+), 53 deletions(-) create mode 100644 app/config/kakao.env.example create mode 100644 app/firebase/functions/src/account/kakao.ts create mode 100644 app/firebase/functions/test/auth_kakao.test.ts create mode 100644 app/test/auth_kakao_test.dart create mode 100644 app/test/auth_oidc_test.dart diff --git a/app/.gitignore b/app/.gitignore index 578cdc8..82a6df1 100644 --- a/app/.gitignore +++ b/app/.gitignore @@ -65,3 +65,6 @@ ios/config/Google.xcconfig firebase/**/node_modules/ firebase/**/*-debug.log firebase/functions/lib/ + +# 카카오 네이티브 앱 키. 다른 설정 파일(google-services.json 등)과 같이 저장소 밖에서 넣는다. +config/kakao.env diff --git a/app/BUILD.md b/app/BUILD.md index 761cf8e..97624c9 100644 --- a/app/BUILD.md +++ b/app/BUILD.md @@ -54,6 +54,25 @@ fvm flutter build apk --release # build/app/outputs/flutter-apk/app-relea fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab ``` +## 카카오 · LINE 로그인 설정 + +**카카오 네이티브 앱 키** — `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` 로 받는다(저장소 밖). + +```bash +fvm flutter build apk --release --dart-define-from-file=config/kakao.env +``` + +**LINE** 은 Firebase Authentication 의 OpenID Connect 제공자 `oidc.line` 이다. 앱에 넣을 값은 없고, +콘솔 Authentication → 로그인 방법에 LINE Login 채널 ID · 채널 시크릿 · issuer +`https://access.line.me` 를 등록하고, LINE Developers 콘솔의 Callback URL 에 +`https://<프로젝트 ID>.firebaseapp.com/__/auth/handler` 를 넣는다. 콘솔에서 켜지 않았으면 로그인이 +E-AUTH-11 로 끝난다. + ## 서명 `android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다 diff --git a/app/README.md b/app/README.md index 3cbbfd7..65dc606 100644 --- a/app/README.md +++ b/app/README.md @@ -7,7 +7,7 @@ | | 내용 | |---|---| -| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google/Apple 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) | +| **포함** | 앱 기동 · 코스믹 다크 테마(design-tokens.md 생성) · 탭 셸 네비게이션 · 로그인 화면(S-A-LOGIN) 소셜 4종 버튼 · 인증 인터페이스와 Google·Apple·카카오·LINE 실 코드 경로 · 계정 연결(linkWithCredential) · Firebase 초기화(미설정 시 목 모드) | | **미포함** | 각 탭의 실제 화면(3주차 이후) · 온보딩 나머지 6화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · S-A-PLANET-NEW · S-A-PERMS) · 도메인 로직(POP·FPI·퀘스트·이클립스) · 상태관리 라이브러리 | 미포함 항목을 지금 만들지 않은 이유: **RN/Flutter 승인이 아직 없다(2주차_확인요청항목 Q1).** @@ -113,8 +113,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; | ID | 항목 | 투입 위치 | 없으면 | |---|---|---|---| -| Q3 | 카카오 네이티브 앱 키 | 미정(플러그인 도입 시 확정) | 카카오 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | -| Q3 | LINE Login 채널 ID / 시크릿 | 미정 | LINE 버튼이 「키 대기」 안내 다이얼로그를 띄운다 | +| Q3 | 카카오 네이티브 앱 키 | `config/kakao.env`(저장소 밖) | 카카오 버튼이 「설정 전」 안내 다이얼로그를 띄운다 | +| Q3 | LINE Login 채널 ID / 시크릿 | Firebase 콘솔 `oidc.line` 제공자 | LINE 로그인이 E-AUTH-11 로 끝난다 | | Q5 | 스토어 등록 명의 | — | 3주차 TestFlight·Play 내부트랙 제출 불가 | | ~~Q14~~ | 앱 식별자 확정 | `android/app/build.gradle.kts` · `ios/Runner.xcodeproj` | **2026-09-11 확정 — 별첨 C 4번 `kr.co.fanuzu.app`.** Android applicationId·namespace·Kotlin 패키지, iOS bundle id 를 모두 옮겼다. **스토어 등록 후에는 변경 불가** | | ~~Q6~~ | 공식 FANUZU 심볼 원본 | `assets/brand/` | **2026-08-13 반영 완료.** 제공본이 심볼 전용이라 앱 아이콘은 공식 자산으로 생성했고 로그인 워드마크는 `FANUZU` 텍스트를 유지 | @@ -145,8 +145,8 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; |---|---|---|---| | Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 | | Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI | -| 카카오 | 키 대기 | `KeylessSocialProvider` | 카카오 네이티브 앱 키(Q3) | -| LINE | 키 대기 | `KeylessSocialProvider` | LINE Login 채널 ID/시크릿(Q3) | +| 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` | +| LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 | 키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지 **안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다. @@ -316,7 +316,7 @@ I flutter : [FirebaseBoot] detail :: PlatformException(java.lang.Exception: Fail |---|---|---|---| | L1 | Firebase 설정 파일 미투입 → 목 모드 | **실 소셜 로그인 불가.** 로그인 버튼은 「키 미투입」 안내를 반환 | S2 (3주차 빌드 전) | | L2 | `com.google.gms.google-services` Gradle 플러그인 **미적용** | 지금은 이게 정상이다. 설정 파일 없이 플러그인만 적용하면 Gradle 빌드가 실패한다. 파일 투입과 **동시에** `android/settings.gradle.kts` · `android/app/build.gradle.kts` 에 추가해야 한다 | S2 | -| L3 | 카카오·LINE SDK 미도입 | 「키 대기」 배지 + 안내 다이얼로그까지만 있다. 키가 오면 `kakao_flutter_sdk_user` · LINE SDK 를 넣고 `KeylessSocialProvider` 를 `CredentialProvider` 구현체로 교체하면 로그인·계정 연결이 함께 열린다. Firebase 쪽은 OIDC 커스텀 제공자(`oidc.kakao` · `oidc.line`)로 등록한다 | Q3 회신 후 | +| L3 | 카카오 · LINE 로그인 연결 | 카카오는 SDK + 서버 커스텀 토큰(`fnAuthKakao`, 사용자 제공자 정보는 `oidc.kakao`), LINE 은 Firebase OIDC(`oidc.line`)다. 설정 방법은 `BUILD.md` 「카카오 · LINE 로그인 설정」 | 반영 완료 | | L3-1 | 「동일 이메일만 연동」이 Apple 재연동에서는 판정되지 않는다 | Apple 이 **최초 인가 때만** 이메일을 준다. 클라이언트에서 볼 값이 없어 통과시킨다 — 서버(fn-account-link)에 같은 판정을 두어야 완결된다 | Firestore 설계서 v1.1 | | L4 | 폰트 파일 미투입 | 시스템 폰트로 폴백. 크기·굵기·행간·자간은 토큰 값대로 적용됨 | 라이선스 원본 확보 시 | | L6 | 탭 아이콘이 Material 기본(Q15) | 시안 탭바에는 아이콘이 없어 대조 대상이 없다 | 3주차 | 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 6552c21..177d060 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -38,6 +38,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 ea327e0..fa9b911 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -9,6 +9,9 @@ import { HttpsError } from 'firebase-functions/v2/https'; export const ERR = { permissionDenied: 'E-PERM-01', authFailed: 'E-AUTH-03', + // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다. + authCredentialInUse: 'E-AUTH-08', + authAlreadyLinked: 'E-AUTH-09', } as const; export type ErrorCode = (typeof ERR)[keyof typeof ERR]; @@ -20,12 +23,14 @@ type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists' const STATUS: Record = { 'E-PERM-01': 'permission-denied', 'E-AUTH-03': 'permission-denied', + 'E-AUTH-08': 'already-exists', + 'E-AUTH-09': 'already-exists', }; /** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */ export class CatalogError extends HttpsError { - constructor(readonly catalogCode: ErrorCode, message: string) { - super(STATUS[catalogCode], message, { code: catalogCode }); + constructor(readonly catalogCode: ErrorCode, message: string, extra: Record = {}) { + super(STATUS[catalogCode], message, { ...extra, code: catalogCode }); } } @@ -36,8 +41,8 @@ export class CatalogError extends HttpsError { * => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의 * 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다. */ -export function fail(code: ErrorCode, message: string): never { - throw new CatalogError(code, message); +export function fail(code: ErrorCode, message: string, extra?: Record): never { + throw new CatalogError(code, message, extra); } /** diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 71df7df..4330ebd 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -6,4 +6,5 @@ import './core/bootstrap.js'; export { fnAuthOnCreate, fnConsentRecord, fnAdminSetRole, } from './account/account.js'; +export { fnAuthKakao } from './account/kakao.js'; export { fnAdminWrite } from './admin/admin.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/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 093bdc1..470c539 100644 --- a/app/ios/Flutter/Debug.xcconfig +++ b/app/ios/Flutter/Debug.xcconfig @@ -4,3 +4,7 @@ // Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다. // `#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 02fef1a..056b1e0 100644 --- a/app/ios/Flutter/Release.xcconfig +++ b/app/ios/Flutter/Release.xcconfig @@ -4,3 +4,7 @@ // Google 로그인 콜백 스킴. tool/ios/inject_url_scheme.py 가 만든다. // `#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 8798140..0bf47dc 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -89,6 +89,22 @@ $(GOOGLE_REVERSED_CLIENT_ID) + + + CFBundleTypeRole + Editor + CFBundleURLName + kakao-login + CFBundleURLSchemes + + kakao$(FANUZU_KAKAO_NATIVE_APP_KEY) + + + + + LSApplicationQueriesSchemes + + kakaokompassauth diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index 6c4196f..1b9462b 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -1,7 +1,7 @@ /// 인증 계약 — 제공자 인터페이스와 결과 타입. /// -/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Apple·카카오·LINE 은 발주사 사업자 -/// 정보로 각 개발자 콘솔에 앱을 등록해야 키가 나온다. "아직 준비가 안 됐다"이지 +/// 원칙: **"키 미투입"은 예외가 아니라 상태다.** Firebase 설정 파일이나 Apple +/// 웹 인증 값처럼 빌드에 들어가야 할 값이 없을 때가 그렇다. "아직 준비가 안 됐다"이지 /// "지원하지 않는다"가 아니므로 예외를 던지지 않고 [AuthNotConfigured] 로 돌려준다. /// 화면은 이걸 안내 다이얼로그로 보여 준다. library; diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index fe4cbdf..ad09900 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; +import 'firebase_account_linker.dart'; import 'social_providers.dart'; /// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. @@ -44,18 +45,26 @@ class AuthRepository extends ChangeNotifier { /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. /// QA 경로는 [qaEmailProvider] 하나뿐이다. /// - /// Apple·카카오·LINE 은 발주사 키가 없어 「키 대기」 상태다. 눌러도 예외가 아니라 - /// [AuthNotConfigured] 가 돌아오고 화면이 안내 다이얼로그를 띄운다. + /// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC + /// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를 + /// 돌려준다. static List _defaultProviders() => [ const GoogleSignInProvider(), AppleSignInProvider( webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), ), - const KeylessSocialProvider(AuthProviderKind.kakao, [ - '카카오 네이티브 앱 키', - '카카오 개발자 콘솔 앱 등록(사업자 정보 필요)', - ]), - lineProvider, + // 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(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'}, + ), ]; /// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**. diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index d3b4f11..4e01972 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -26,7 +26,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', @@ -55,13 +56,16 @@ final class FirebaseAccountLinker implements AccountLinker { } final missing = impl.missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); - // 키 대기 제공자(카카오·LINE)는 자격증명을 만들 수 없다. - if (impl is! CredentialProvider) { + if (impl is! CredentialProvider && + impl is! OidcSignInProvider && + impl is! KakaoSignInProvider) { return AuthNotConfigured(provider, const ['자격증명 구현']); } - final source = impl as CredentialProvider; 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(); // 사용자가 소셜 창을 닫았다. 실패로 세지 않는다. if (obtained == null) return const AuthCancelled(); @@ -83,6 +87,7 @@ final class FirebaseAccountLinker implements AccountLinker { final result = await user.linkWithCredential(obtained.credential); return AuthSucceeded(_accountOf(result.user, provider)); } on fb.FirebaseAuthException catch (error) { + if (isWebFlowCancelled(error)) return const AuthCancelled(); return AuthFailed( authCatalogCode(error), authMessage(error, flow: AuthFlow.link), @@ -120,6 +125,33 @@ final class FirebaseAccountLinker implements AccountLinker { } } + /// 카카오·LINE(OIDC) 연결. + /// + /// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다. + /// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다. + /// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의 + /// blocking 함수(beforeSignIn)로 서버에서 거른다. + Future _linkOidc(fb.User user, OidcSignInProvider impl) async { + final mine = user.email; + final result = await untilAbandoned( + user.linkWithProvider(impl.authProvider), + ); + // 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때). + final theirs = _emailOf(result.user, impl.providerId); + if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) { + await user.unlink(impl.providerId); + return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.'); + } + return AuthSucceeded(_accountOf(result.user, impl.provider)); + } + + static String? _emailOf(fb.User? user, String providerId) { + for (final info in user?.providerData ?? const []) { + if (info.providerId == providerId) return info.email; + } + return null; + } + SocialAuthProvider? _implOf(AuthProviderKind provider) { for (final candidate in _providers) { if (candidate.provider == provider) return candidate; diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 4bb0041..08f0bd6 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'; @@ -198,29 +204,278 @@ final class AppleSignInProvider kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// 발주사 키를 기다리는 제공자 — 카카오·LINE. +/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나 +/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다). /// -/// 예외를 던지지 않는다. [AuthNotConfigured] 로 "무엇이 없어서 못 쓰는지"를 돌려주고 -/// 로그인 화면이 그걸 안내 다이얼로그로 띄운다. 키가 들어오면 이 자리에 실 구현체를 -/// 넣고 [AuthRepository] 목록만 바꾸면 된다 — 화면·라우터는 그대로다. -final class KeylessSocialProvider implements SocialAuthProvider { - const KeylessSocialProvider(this.provider, this.missingConfig); +/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을 +/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이 +/// `signInWithProvider` 하나로 끝난다 — Android 는 Custom Tabs, iOS 는 +/// SFSafariViewController 로 인가 화면을 띄우고 Firebase 핸들러 +/// (`https://<프로젝트>.firebaseapp.com/__/auth/handler`)로 돌아온다. +/// iOS 콜백 스킴은 Google 로그인용 REVERSED_CLIENT_ID 를 같이 쓴다. +/// +/// 콘솔에서 제공자를 켜지 않았으면 Firebase 가 `operation-not-allowed` 를 돌려주고 +/// [authCatalogCode] 가 E-AUTH-11 로 옮긴다 — 앱이 따로 플래그를 들 필요가 없다. +/// +/// [CredentialProvider] 가 아니다. 웹 흐름이 자격증명 발급과 로그인·연결을 한 번에 +/// 끝내서 자격증명만 먼저 받아 볼 수 없다. 계정 연결은 [FirebaseAccountLinker] 가 +/// [authProvider] 로 `linkWithProvider` 를 부른다. +final class OidcSignInProvider implements SocialAuthProvider { + const OidcSignInProvider( + this.provider, { + required this.providerId, + this.scopes = const [], + this.androidParameters = const {}, + this.firebaseAuth, + }); @override final SocialProvider provider; - @override - final List missingConfig; + /// Firebase 콘솔에 등록한 제공자 ID. `oidc.` 접두는 콘솔이 강제한다. + final String providerId; + + /// `openid` 외에 더 받을 항목. Firebase 가 `openid` 는 알아서 붙인다. + /// **콘솔에 켠 동의 항목과 맞아야 한다** — 카카오는 설정에 없는 scope 를 요청하면 + /// 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; @override - bool get isLive => false; + bool get isLive => true; @override - Future signIn() async => - AuthNotConfigured(provider, missingConfig); + List get missingConfig => + FirebaseBoot.isLive ? const [] : FirebaseBoot.missingLabels; + + fb.OAuthProvider get authProvider { + final p = fb.OAuthProvider(providerId); + for (final scope in scopes) { + p.addScope(scope); + } + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { + p.setCustomParameters(androidParameters); + } + return p; + } @override - Future signOut() async {} + Future signIn() async { + final missing = missingConfig; + if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); + try { + 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(); + return AuthFailed( + authCatalogCode(error), + authMessage(error, flow: AuthFlow.signIn), + ); + } + } + + @override + Future signOut() async { + // 카카오·LINE 쪽 세션은 브라우저 쿠키라 앱이 지울 수 없다. Firebase 세션은 + // AuthSession 이 닫는다. + } +} + +/// 카카오 네이티브 앱 키. `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 const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); + } on kakao.KakaoAuthException catch (error) { + // 동의 화면에서 「취소」. + if (error.error == kakao.AuthErrorCause.accessDenied) { + return const AuthCancelled(); + } + return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); + } 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' => + '가입하신 이메일과 다른 계정입니다.', + 'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + 'E-AUTH-09' => '이미 연결된 계정입니다.', + _ => + flow == AuthFlow.link + ? '계정 연결에 실패했습니다.' + : '소셜 로그인에 실패했습니다.', + }); + } 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은 비밀값이 아니지만 환경별 값이다. @@ -336,14 +591,6 @@ final class EmailPasswordProvider implements EmailAuthProvider { } } -/// LINE — 발주사 키 대기. -/// -/// 별첨 B v1.4 확정 4종에 LINE 이 있고 네이버·X 는 없다. -const lineProvider = KeylessSocialProvider(AuthProviderKind.line, [ - 'LINE Login 채널 ID / 채널 시크릿', - 'LINE Developers 콘솔 채널 등록(사업자 정보 필요)', -]); - /// SDK 코드를 에러 카탈로그 ID 로 옮긴다. /// /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 diff --git a/app/pubspec.lock b/app/pubspec.lock index a8e2be4..0dad837 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: @@ -162,13 +178,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: @@ -307,6 +355,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.0.0" + 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 @@ -407,6 +463,14 @@ packages: description: flutter source: sdk version: "0.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: @@ -415,6 +479,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: @@ -487,6 +575,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.0" + mime: + dependency: transitive + description: + name: mime + sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6 + url: "https://pub.dev" + source: hosted + version: "2.1.0" mock_exceptions: dependency: transitive description: @@ -511,6 +607,38 @@ 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_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: "58c2005f147315b11e9b4a7bc889cd5203e250cba8e3f012dae259b4972b5c16" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "484838772624c3a4b94f1e44a3e19897fee738f2d5c4ce448443b0417f7c9dda" + url: "https://pub.dev" + source: hosted + version: "2.1.3" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.dev" + source: hosted + version: "2.3.0" petitparser: dependency: transitive description: @@ -539,10 +667,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: @@ -575,6 +703,62 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + shared_preferences: + dependency: transitive + description: + name: shared_preferences + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" + url: "https://pub.dev" + source: hosted + version: "2.5.6" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.dev" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.dev" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.dev" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.dev" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.dev" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.dev" + source: hosted + version: "2.4.1" sign_in_with_apple: dependency: "direct main" description: @@ -676,6 +860,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: @@ -708,6 +916,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.dev" + source: hosted + version: "1.1.0" xml: dependency: transitive description: @@ -725,5 +941,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.11.5 <4.0.0" - flutter: ">=3.41.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" diff --git a/app/pubspec.yaml b/app/pubspec.yaml index ebf0a12..bacda37 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -29,9 +29,14 @@ dependencies: # Apple Sign-In nonce를 SHA-256으로 해시해 replay 공격을 막는다. crypto: ^3.0.7 - # 소셜 로그인 — Google·Apple 만 실 코드 경로. 카카오·네이버는 앱 키가 없어 스텁이다(Q3). + # 소셜 로그인 — Google·Apple 은 각 SDK, LINE 은 Firebase OIDC(SDK 없음), 카카오는 아래 SDK. google_sign_in: ^7.2.0 sign_in_with_apple: ^8.1.0 + # 카카오 로그인 — Kakao 공식 SDK. 카카오톡이 있으면 앱으로, 없으면 카카오계정 웹으로 로그인해 + # 액세스 토큰을 받고 서버(fnAuthKakao)가 Firebase 커스텀 토큰으로 바꿔 준다. Android 에서 + # Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다. + # 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0). + kakao_flutter_sdk_user: ^2.0.1 dev_dependencies: flutter_test: @@ -49,6 +54,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 new file mode 100644 index 0000000..6c4fc23 --- /dev/null +++ b/app/test/auth_oidc_test.dart @@ -0,0 +1,177 @@ +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'; +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 제공자 — 실 로그인은 못 하고, +/// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다. +void main() { + // Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned). + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'OIDC 테스트')); + tearDown( + () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'OIDC 테스트 끝'), + ); + + OidcSignInProvider line(fb.FirebaseAuth auth) => OidcSignInProvider( + AuthProviderKind.line, + providerId: 'oidc.line', + firebaseAuth: auth, + ); + + test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () { + final providers = { + for (final p in AuthRepository().providers) p.provider: p, + }; + 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 line(_ThrowingAuth('should-not-call')).signIn(); + expect(outcome, isA()); + }); + + test('성공하면 LINE 계정으로 로그인된다', () async { + final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'k-1')); + final outcome = await line(auth).signIn(); + expect(outcome, isA()); + 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 line(_ThrowingAuth(code)).signIn(); + expect(outcome, isA(), reason: code); + } + }); + + test('콘솔에서 제공자를 안 켰으면 E-AUTH-11', () async { + 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.line 을 붙인다', () async { + final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.line); + + expect(outcome, isA()); + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); + }); + + test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async { + final user = _LinkingUser(linkedEmail: 'someone@else.com'); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.line); + + expect((outcome as AuthFailed).code, 'E-AUTH-08'); + expect( + linker.linkedAccounts.keys, + isNot(contains(AuthProviderKind.line)), + ); + }); + }); +} + +class _ThrowingAuth extends MockFirebaseAuth { + _ThrowingAuth(this.code); + final String code; + + @override + Future signInWithProvider(fb.AuthProvider provider) => + 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 { + _LinkingUser({required this.linkedEmail}) + : super( + uid: 'u-1', + email: 'fan@fanuzu.co.kr', + providerData: [_info('google.com', 'fan@fanuzu.co.kr')], + ); + + final String linkedEmail; + + @override + Future linkWithProvider(fb.AuthProvider provider) async { + final result = await super.linkWithProvider(provider); + providerData + ..removeLast() + ..add(_info(provider.providerId, linkedEmail)); + return result; + } +} + +fb.UserInfo _info(String providerId, String email) => + // ignore: invalid_use_of_protected_member + fb.UserInfo.fromJson({ + 'uid': 'p-$providerId', + 'email': email, + 'providerId': providerId, + 'isAnonymous': false, + 'isEmailVerified': true, + }); From 9d34935b94a34548d51e1f6ea1075558783d4f5c Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 5 Oct 2026 21:53:23 +0900 Subject: [PATCH 02/19] =?UTF-8?q?Kakao=C2=B7LINE=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=84=A0=ED=83=9D=20=EC=88=98=EC=A7=91=20=E2=80=94?= =?UTF-8?q?=20=EB=8F=99=EC=9D=98=ED=95=9C=20=EC=9D=B4=EB=A9=94=EC=9D=BC?= =?UTF-8?q?=EB=A7=8C=20=EC=A0=80=EC=9E=A5=20=C2=B7=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=97=86=EC=9D=B4=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20?= =?UTF-8?q?=EC=8B=9D=EB=B3=84=EA=B0=92=EC=9C=BC=EB=A1=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=C2=B7=EB=A1=9C=EA=B7=B8=EC=9D=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/README.md | 4 ++ app/firebase/functions/src/account/account.ts | 28 +++++--- app/firebase/functions/src/account/kakao.ts | 6 ++ .../functions/test/account_email.test.ts | 71 +++++++++++++++++++ 4 files changed, 101 insertions(+), 8 deletions(-) create mode 100644 app/firebase/functions/test/account_email.test.ts diff --git a/app/README.md b/app/README.md index 65dc606..a06319a 100644 --- a/app/README.md +++ b/app/README.md @@ -148,6 +148,10 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; | 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` | | LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 | +카카오 · LINE 의 이메일은 **선택 동의 항목**이다. 이메일을 받지 못하거나 사용자가 동의하지 않아도 +제공자 고유 식별값(카카오 회원번호 · LINE 사용자 ID)으로 가입 · 로그인되고, 같은 계정으로 다시 들어오면 +같은 UID 로 로그인된다. 동의한 이메일만 사용자 문서(`users/{uid}.email`)에 남는다. + 키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지 **안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다. diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 80bb0c7..72bfd28 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -18,13 +18,18 @@ import { ERR, fail } from '../core/errors.js'; */ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { const db = getFirestore(); - const batch = db.batch(); - - batch.set(db.doc(`users/${user.uid}`), { + const userRef = db.doc(`users/${user.uid}`); + const walletRef = db.doc(`users/${user.uid}/wallet/main`); + // 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다. + // Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는 + // 계정을 만든 뒤에 연결 정보가 붙으므로 `fnAuthKakao` 가 따로 쓴다. + const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null; + const defaults: Record = { uid: user.uid, displayName: user.displayName ?? '팬우주 여행자', photoUrl: user.photoURL ?? null, countryCode: 'KR', + email, locale: 'ko', fandomId: '', authProviders: user.providerData.map((p) => p.providerId), @@ -34,18 +39,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { notificationPrefs: {}, createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), - }, { merge: true }); - - batch.set(db.doc(`users/${user.uid}/wallet/main`), { + }; + const wallet: Record = { activityBalance: 0, supportBalance: 0, pendingActivity: 0, reconcileStatus: 'ok', createdAt: FieldValue.serverTimestamp(), updatedAt: FieldValue.serverTimestamp(), - }, { merge: true }); + }; - await batch.commit(); + // 트리거가 늦게 돌면 그 사이 서버가 쓴 값(카카오 동의 이메일 등)이 이미 있다. + // 없는 필드만 채운다 — 덮어쓰면 먼저 쓴 값이 기본값으로 되돌아간다. + await db.runTransaction(async (tx) => { + const [u, w] = await Promise.all([tx.get(userRef), tx.get(walletRef)]); + const missing = (base: Record, have: Record | undefined) => + Object.fromEntries(Object.entries(base).filter(([k]) => have?.[k] == null)); + tx.set(userRef, missing(defaults, u.data()), { merge: true }); + tx.set(walletRef, missing(wallet, w.data()), { merge: true }); + }); }); /** diff --git a/app/firebase/functions/src/account/kakao.ts b/app/firebase/functions/src/account/kakao.ts index 346cd4f..e25e40a 100644 --- a/app/firebase/functions/src/account/kakao.ts +++ b/app/firebase/functions/src/account/kakao.ts @@ -1,4 +1,5 @@ import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; import { defineString } from 'firebase-functions/params'; import { onCall } from 'firebase-functions/v2/https'; @@ -139,5 +140,10 @@ export const fnAuthKakao = onCall(async (req) => { return { linked: true }; } const uid = await resolveKakaoUid(profile); + // 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이 + // 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다. + if (profile.email) { + await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true }); + } return { token: await getAuth().createCustomToken(uid) }; }); diff --git a/app/firebase/functions/test/account_email.test.ts b/app/firebase/functions/test/account_email.test.ts new file mode 100644 index 0000000..f9fae10 --- /dev/null +++ b/app/firebase/functions/test/account_email.test.ts @@ -0,0 +1,71 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; + +import { bootAdmin, shutdownAdmin } from './support.js'; +import { fnAuthOnCreate } from '../src/account/account.js'; +import { fnAuthKakao, kakaoUidOf } from '../src/account/kakao.js'; + +/** 이메일 선택 수집 — 동의한 이메일만 남기고, 이메일이 없어도 가입 · 로그인된다. */ + +const U = 'test-email-user'; +const KAKAO_ID = '555000111'; +const db = () => getFirestore(); + +type KakaoReq = Parameters[0]; +const onCreate = (uid: string, extra: Record = {}) => + (fnAuthOnCreate as unknown as { run: (u: unknown) => Promise }) + .run({ uid, email: undefined, displayName: undefined, photoURL: undefined, providerData: [], ...extra }); + +function stubKakao(email?: string) { + vi.stubGlobal('fetch', vi.fn(async (url: string) => { + if (url.endsWith('/v1/user/access_token_info')) return Response.json({ id: Number(KAKAO_ID), app_id: 123 }); + return Response.json({ + id: Number(KAKAO_ID), + kakao_account: { profile: { nickname: '카카오' }, ...(email ? { email, is_email_valid: true, is_email_verified: true } : {}) }, + }); + })); +} + +async function wipe() { + await getAuth().deleteUsers([U, kakaoUidOf(KAKAO_ID)]); + for (const uid of [U, kakaoUidOf(KAKAO_ID)]) await db().recursiveDelete(db().doc(`users/${uid}`)); +} + +beforeAll(() => { + if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다.'); + process.env.KAKAO_APP_ID = '123'; + bootAdmin(); +}); +afterAll(async () => { await wipe(); await shutdownAdmin(); }); +beforeEach(wipe); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('계정 생성 트리거', () => { + it('제공자 정보의 이메일을 쓰고 · 이미 쓴 값은 덮지 않는다', async () => { + await db().doc(`users/${U}`).set({ displayName: '먼저씀' }); + await onCreate(U, { providerData: [{ providerId: 'oidc.line', email: 'line@fan.io' }] }); + const user = (await db().doc(`users/${U}`).get()).data()!; + expect(user.displayName).toBe('먼저씀'); + expect(user.email).toBe('line@fan.io'); + expect(user.status).toBe('active'); + expect((await db().doc(`users/${U}/wallet/main`).get()).get('activityBalance')).toBe(0); + }); + + it('이메일이 없으면 null 로 만든다', async () => { + await onCreate(U, { providerData: [{ providerId: 'oidc.line' }] }); + expect((await db().doc(`users/${U}`).get()).get('email')).toBeNull(); + }); +}); + +describe('카카오 이메일', () => { + it('동의한 이메일만 사용자 문서에 남기고 · 없어도 로그인된다', async () => { + stubKakao(); + const first = await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq) as { token: string }; + expect(first.token).toBeTruthy(); + expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBeUndefined(); + stubKakao('fan@kakao.com'); + await fnAuthKakao.run({ data: { accessToken: 'good' } } as KakaoReq); + expect((await db().doc(`users/${kakaoUidOf(KAKAO_ID)}`).get()).get('email')).toBe('fan@kakao.com'); + }); +}); From 7bfc4faa134e9f9e5bd0dea4e7bf4e15f86e7b23 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 02:24:26 +0900 Subject: [PATCH 03/19] =?UTF-8?q?Kakao=20=EC=9D=B8=EA=B0=80=C2=B7=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=8B=A8=EA=B3=84=20=EC=8B=A4=ED=8C=A8=EB=A5=BC=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=A4=ED=8C=A8=EB=A1=9C=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=C2=B7=20=EC=8B=A4=ED=8C=A8=20=EC=9B=90?= =?UTF-8?q?=EC=9D=B8=20=EB=A1=9C=EA=B7=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/auth/social_providers.dart | 12 ++++++++---- app/test/auth_kakao_test.dart | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 4 deletions(-) diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 08f0bd6..7ab2b18 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -396,6 +396,13 @@ final class KakaoSignInProvider implements SocialAuthProvider { if (error.error == kakao.AuthErrorCause.accessDenied) { return const AuthCancelled(); } + // 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다. + debugPrint( + '[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}', + ); + return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); + } on kakao.KakaoException catch (error) { + debugPrint('[KakaoSignInProvider] sdk error: $error'); return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on FirebaseFunctionsException catch (error) { final details = error.details; @@ -407,10 +414,7 @@ final class KakaoSignInProvider implements SocialAuthProvider { '가입하신 이메일과 다른 계정입니다.', 'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', 'E-AUTH-09' => '이미 연결된 계정입니다.', - _ => - flow == AuthFlow.link - ? '계정 연결에 실패했습니다.' - : '소셜 로그인에 실패했습니다.', + _ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', }); } on fb.FirebaseAuthException catch (error) { return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow)); diff --git a/app/test/auth_kakao_test.dart b/app/test/auth_kakao_test.dart index 6af6143..2e89df0 100644 --- a/app/test/auth_kakao_test.dart +++ b/app/test/auth_kakao_test.dart @@ -6,6 +6,8 @@ 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'; +import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' + as kakao_sdk; /// 카카오 — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약. /// 토큰 검증·기존 oidc.kakao 사용자 매핑은 서버 테스트(functions/test/auth_kakao.test.ts)가 본다. @@ -61,6 +63,23 @@ void main() { expect(sent, isEmpty); }); + test('카카오 인가 · 토큰 단계 실패는 로그인 실패(E-AUTH-03) · 서버를 부르지 않는다', () async { + for (final error in [ + kakao_sdk.KakaoAuthException( + kakao_sdk.AuthErrorCause.misconfigured, + 'KOE', + ), + kakao_sdk.KakaoClientException(kakao_sdk.ClientErrorCause.unknown, 'sdk'), + ]) { + final outcome = await kakao( + MockFirebaseAuth(), + token: () async => throw error, + ).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03', reason: '$error'); + } + expect(sent, isEmpty); + }); + test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async { final auth = MockFirebaseAuth(); final outcome = await kakao( From b66863a149078f49597ef54ea8abda4acd37820e Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Thu, 8 Oct 2026 15:44:49 +0900 Subject: [PATCH 04/19] =?UTF-8?q?Android=20Apple=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=EC=9D=84=20Firebase=20=EC=9B=B9=20=ED=9D=90=EB=A6=84(?= =?UTF-8?q?apple.com=20=EC=A0=9C=EA=B3=B5=EC=9E=90)=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=97=B0=EA=B2=B0=20=C2=B7=20=EC=84=A4=EC=A0=95=20=EC=95=88?= =?UTF-8?q?=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/BUILD.md | 14 ++++++++++++++ app/README.md | 2 +- app/lib/features/auth/auth_repository.dart | 13 ++++++++++--- app/lib/features/auth/social_providers.dart | 19 ------------------- app/test/auth_oidc_test.dart | 18 ++++++++++++++++++ 5 files changed, 43 insertions(+), 23 deletions(-) diff --git a/app/BUILD.md b/app/BUILD.md index 97624c9..b40ff73 100644 --- a/app/BUILD.md +++ b/app/BUILD.md @@ -73,6 +73,20 @@ fvm flutter build apk --release --dart-define-from-file=config/kakao.env `https://<프로젝트 ID>.firebaseapp.com/__/auth/handler` 를 넣는다. 콘솔에서 켜지 않았으면 로그인이 E-AUTH-11 로 끝난다. +## Android Apple 로그인 — 앱에 넣을 값 없음 + +Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자 · `signInWithProvider`)으로 로그인한다. +값은 모두 콘솔에 둔다 — 빌드 옵션은 없다. + +1. Apple 개발자 계정 「Identifiers → Services IDs」에 서비스 ID 를 만들고 「Sign in with Apple」을 켠다. + - Domains: `<프로젝트 ID>.firebaseapp.com` + - Return URLs: `https://<프로젝트 ID>.firebaseapp.com/__/auth/handler` +2. 「Keys」에서 Sign in with Apple 키를 만들고 .p8 를 받는다(키 ID · 팀 ID 기록). 키 파일은 저장소에 넣지 않는다. +3. Firebase 콘솔 Authentication → 로그인 방법 → Apple 에 서비스 ID · 팀 ID · 키 ID · 비공개 키를 넣는다. +4. 확인: Android 첫 화면 Apple → 브라우저 탭에서 Apple 로그인 → 앱 HOME. + +iOS 는 지금처럼 Apple SDK(`AppleSignInProvider`)로 로그인한다. + ## 서명 `android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다 diff --git a/app/README.md b/app/README.md index a06319a..12d980d 100644 --- a/app/README.md +++ b/app/README.md @@ -144,7 +144,7 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode; | 제공자 | 상태 | 구현 | 막고 있는 것 | |---|---|---|---| | Google | 실 코드 경로 | `GoogleSignInProvider` | **Firebase 콘솔에서 Google 제공자 미활성** + Android OAuth 클라이언트(SHA-1) 미등록 | -| Apple | 실 코드 경로 | `AppleSignInProvider` | Apple 개발자 키 + Xcode Capability + Android Service ID/redirect URI | +| Apple | 실 코드 경로 | iOS `AppleSignInProvider`(Apple SDK) · Android `OidcSignInProvider`(Firebase 웹 흐름 `apple.com`) | iOS Xcode Capability · Android 는 Firebase 콘솔 Apple 제공자에 서비스 ID · 키 등록(`BUILD.md` 「Android Apple 로그인」) | | 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` | | LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 | diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index ad09900..a25591f 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -50,9 +50,16 @@ class AuthRepository extends ChangeNotifier { /// 돌려준다. static List _defaultProviders() => [ const GoogleSignInProvider(), - AppleSignInProvider( - webAuthenticationOptions: appleWebAuthenticationOptionsFromEnvironment(), - ), + // Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 — + // 서비스 ID · 키는 Firebase 콘솔 Apple 제공자 설정에 두고 앱에는 값이 없다. + if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) + const OidcSignInProvider( + AuthProviderKind.apple, + providerId: 'apple.com', + scopes: ['email', 'name'], + ) + else + const AppleSignInProvider(), // 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱 // 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다. KakaoSignInProvider(), diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 7ab2b18..445b647 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -482,25 +482,6 @@ Future untilAbandoned( }); } -/// Apple Service ID와 callback URL은 비밀값이 아니지만 환경별 값이다. -/// 저장소에 임시 상수를 박지 않고 빌드 시 `--dart-define`으로만 받는다. -apple.WebAuthenticationOptions? appleWebAuthenticationOptionsFromEnvironment() { - const clientId = String.fromEnvironment('FANUZU_APPLE_SERVICE_ID'); - const redirect = String.fromEnvironment('FANUZU_APPLE_REDIRECT_URI'); - if (clientId.isEmpty || redirect.isEmpty) return null; - - final redirectUri = Uri.tryParse(redirect); - if (redirectUri == null || - !redirectUri.hasScheme || - !redirectUri.hasAuthority) { - return null; - } - return apple.WebAuthenticationOptions( - clientId: clientId, - redirectUri: redirectUri, - ); -} - AuthAccount _toAccount(fb.User? user, SocialProvider provider) => AuthAccount( uid: user?.uid ?? '', provider: provider, diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index 6c4fc23..4c9117f 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -42,6 +42,24 @@ void main() { expect(providers[AuthProviderKind.kakao], isA()); }); + test('Apple: Android 는 Firebase 웹 흐름(apple.com) · iOS 는 Apple SDK', () { + final android = AuthRepository().providers.firstWhere( + (p) => p.provider == AuthProviderKind.apple, + ); + expect(android, isA()); + expect( + (android as OidcSignInProvider).authProvider.providerId, + 'apple.com', + ); + expect(android.missingConfig, isEmpty); + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + final ios = AuthRepository().providers.firstWhere( + (p) => p.provider == AuthProviderKind.apple, + ); + expect(ios, isA()); + }); + test('Firebase 설정이 없으면 SDK 를 부르지 않고 미설정으로 닫는다', () async { FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '설정 없음'); final outcome = await line(_ThrowingAuth('should-not-call')).signIn(); From 4d2ce678861310a7207c6423b911eeed623046bb Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sat, 10 Oct 2026 21:14:49 +0900 Subject: [PATCH 05/19] =?UTF-8?q?SNS=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0=EC=9D=98=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=9D=BC?= =?UTF-8?q?=EC=B9=98=20=EC=A1=B0=EA=B1=B4=20=EC=A0=9C=EA=B1=B0=20=E2=80=94?= =?UTF-8?q?=20=ED=98=84=EC=9E=AC=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=EA=B3=BC=20?= =?UTF-8?q?=EC=83=88=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EC=84=9C=EB=B2=84=EC=97=90=EC=84=9C=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=C2=B7=20=EB=8B=A4=EB=A5=B8=20=EA=B3=84=EC=A0=95?= =?UTF-8?q?=EC=97=90=20=EC=97=B0=EA=B2=B0=EB=90=9C=20=EC=A0=9C=EA=B3=B5?= =?UTF-8?q?=EC=9E=90=EB=8A=94=20=EA=B1=B0=EB=B6=80=20=C2=B7=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EB=A7=8C=EB=A3=8C=20=EC=8B=9C=20=EC=9E=AC=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=95=88=EB=82=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/account/kakao.ts | 38 ++++++--- app/firebase/functions/src/core/errors.ts | 2 +- .../functions/test/auth_kakao.test.ts | 33 +++++++- app/lib/features/auth/auth.dart | 15 ++-- .../auth/firebase_account_linker.dart | 38 +-------- app/lib/features/auth/social_providers.dart | 29 ++++--- app/test/auth_kakao_test.dart | 19 ++++- app/test/auth_oidc_test.dart | 82 +++++++++++++++++-- 8 files changed, 175 insertions(+), 81 deletions(-) diff --git a/app/firebase/functions/src/account/kakao.ts b/app/firebase/functions/src/account/kakao.ts index e25e40a..e41b8f9 100644 --- a/app/firebase/functions/src/account/kakao.ts +++ b/app/firebase/functions/src/account/kakao.ts @@ -35,7 +35,7 @@ export interface KakaoProfile { /** 카카오 회원번호. */ id: string; nickname?: string; - /** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */ + /** 동의했고 유효·인증된 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */ email?: string; } @@ -110,25 +110,40 @@ export async function resolveKakaoUid(p: KakaoProfile): Promise { } /** - * SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않는다. + * SNS 연동 — 로그인한 [uid] 에 카카오를 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다. * - * 「동일 이메일만 연동」(명세 `554:22797`)은 붙이기 **전에** 서버에서 본다. 둘 중 하나라도 - * 이메일이 없으면 건너뛴다(앱과 같은 규칙 — 카카오 이메일은 비즈 앱 동의가 있을 때만 온다). + * 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다. + * 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 카카오 계정도 연결된다). + * - 새 Provider 자격: [verifyKakaoToken] 이 우리 앱에서 발급된 유효 토큰인지 카카오 API 로 본다. + * - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이 + * 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」. + * - 그 카카오 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도 + * (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다. */ -export async function linkKakao(uid: string, p: KakaoProfile): Promise { +export async function linkKakao(uid: string, p: KakaoProfile, authTime?: number): Promise { const owner = await ownerOf(p.id); if (owner === uid) return; if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); const user = await getAuth().getUser(uid); + const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0; + if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) { + fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' }); + } 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' }); + try { + await getAuth().updateUser(uid, { providerToLink: linkInfo(p) }); + } catch (e) { + const code = (e as { code?: string }).code ?? ''; + if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) { + fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); + } + throw e; + } + if ((await ownerOf(p.id)) !== uid) { + fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); } - await getAuth().updateUser(uid, { providerToLink: linkInfo(p) }); } export const fnAuthKakao = onCall(async (req) => { @@ -136,7 +151,8 @@ export const fnAuthKakao = onCall(async (req) => { if ((req.data as Record).link === true) { const uid = req.auth?.uid; if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.'); - await linkKakao(uid, profile); + const authTime = req.auth?.token?.auth_time; + await linkKakao(uid, profile, typeof authTime === 'number' ? authTime : undefined); return { linked: true }; } const uid = await resolveKakaoUid(profile); diff --git a/app/firebase/functions/src/core/errors.ts b/app/firebase/functions/src/core/errors.ts index fa9b911..bd2c49e 100644 --- a/app/firebase/functions/src/core/errors.ts +++ b/app/firebase/functions/src/core/errors.ts @@ -9,7 +9,7 @@ import { HttpsError } from 'firebase-functions/v2/https'; export const ERR = { permissionDenied: 'E-PERM-01', authFailed: 'E-AUTH-03', - // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다. + // 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「그 Provider 가 다른 계정에 이미 연결」(이메일 비교 없음). authCredentialInUse: 'E-AUTH-08', authAlreadyLinked: 'E-AUTH-09', } as const; diff --git a/app/firebase/functions/test/auth_kakao.test.ts b/app/firebase/functions/test/auth_kakao.test.ts index 4dcdeb7..ed6688d 100644 --- a/app/firebase/functions/test/auth_kakao.test.ts +++ b/app/firebase/functions/test/auth_kakao.test.ts @@ -96,11 +96,38 @@ describe('SNS 연동', () => { expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08'); }); - it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', async () => { + it('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다', 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 }); + await getAuth().deleteUser(GOOGLE_UID); + await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); + stubKakao(); + 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); + }); + + it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => { + await getAuth().createUser({ uid: GOOGLE_UID }); + const before = Math.floor(Date.now() / 1000) - 60; + await getAuth().revokeRefreshTokens(GOOGLE_UID); + const linkAs = (authTime: number) => fnAuthKakao.run( + { data: { accessToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req, + ); + expect(await codeOf(linkAs(before))).toBe('session'); + await getAuth().updateUser(GOOGLE_UID, { disabled: true }); + expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session'); + expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]); + }); + + it('이미 다른 카카오가 붙어 있으면 E-AUTH-09 · 같은 카카오면 그대로 성공', async () => { + await getAuth().createUser({ uid: GOOGLE_UID }); + await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: '111' } }); + expect(await codeOf(call({ accessToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09'); + await getAuth().deleteUser(GOOGLE_UID); + await getAuth().createUser({ uid: GOOGLE_UID }); + expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); expect(await call({ accessToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); }); }); diff --git a/app/lib/features/auth/auth.dart b/app/lib/features/auth/auth.dart index 1b9462b..a745e52 100644 --- a/app/lib/features/auth/auth.dart +++ b/app/lib/features/auth/auth.dart @@ -133,16 +133,6 @@ abstract interface class AuthSession { Future signOut(); } -/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`. -/// -/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다. -/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이 -/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false. -bool sameAccountIdentity(String a, String b) { - final x = a.trim().toLowerCase(); - return x.contains('@') && x == b.trim().toLowerCase(); -} - /// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종. /// 이메일은 QA 내부 경로라 목록에 없다. const List linkableProviders = [ @@ -156,6 +146,11 @@ const List linkableProviders = [ /// /// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.* /// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다. +/// +/// 연결은 현재 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버가** 모두 확인해 붙인다 +/// (Firebase Auth `linkWithCredential` · 카카오는 `fnAuthKakao`). 이메일 일치는 소유 확인이 아니어서 +/// 비교하지 않는다 — 이메일을 주지 않는 카카오 · LINE 계정도 붙는다. 그 Provider 가 다른 FANUZU UID 에 +/// 붙어 있으면 거부하고(E-AUTH-08) 자동 병합하지 않는다. /// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정 /// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다. abstract interface class AccountLinker { diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index 4e01972..c1f177c 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -70,20 +70,9 @@ final class FirebaseAccountLinker implements AccountLinker { // 사용자가 소셜 창을 닫았다. 실패로 세지 않는다. if (obtained == null) return const AuthCancelled(); - // 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다. - // 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다. - // - // ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가 - // 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서 - // 다시 보려면 Functions 층에 같은 판정을 둔다. - final mine = user.email; - final theirs = obtained.email; - if (mine != null && - theirs != null && - !sameAccountIdentity(mine, theirs)) { - return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.'); - } - + // 이메일을 비교하지 않는다 — 이메일 일치는 소유 확인이 아니다. Firebase 가 지금 로그인 토큰과 + // 새 자격증명을 서버에서 함께 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면 + // credential-already-in-use 로 거부한다. final result = await user.linkWithCredential(obtained.credential); return AuthSucceeded(_accountOf(result.user, provider)); } on fb.FirebaseAuthException catch (error) { @@ -125,33 +114,14 @@ final class FirebaseAccountLinker implements AccountLinker { } } - /// 카카오·LINE(OIDC) 연결. - /// - /// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다. - /// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다. - /// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의 - /// blocking 함수(beforeSignIn)로 서버에서 거른다. + /// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { - final mine = user.email; final result = await untilAbandoned( user.linkWithProvider(impl.authProvider), ); - // 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때). - final theirs = _emailOf(result.user, impl.providerId); - if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) { - await user.unlink(impl.providerId); - return const AuthFailed('E-AUTH-08', '가입하신 이메일과 다른 계정입니다.'); - } return AuthSucceeded(_accountOf(result.user, impl.provider)); } - static String? _emailOf(fb.User? user, String providerId) { - for (final info in user?.providerData ?? const []) { - if (info.providerId == providerId) return info.email; - } - return null; - } - SocialAuthProvider? _implOf(AuthProviderKind provider) { for (final candidate in _providers) { if (candidate.provider == provider) return candidate; diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 445b647..a2671e7 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -25,12 +25,12 @@ abstract interface class CredentialProvider { /// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신 /// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다. /// - /// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`) - /// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다. + /// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는 + /// 쓰지 않는다(이메일 일치는 소유 확인이 아니다). Future obtainCredential(); } -/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일. +/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만). typedef SocialCredential = ({fb.AuthCredential credential, String? email}); /// Google 로그인 — 실 코드 경로. @@ -156,8 +156,7 @@ final class AppleSignInProvider accessToken: credential.authorizationCode, rawNonce: rawNonce, ), - // Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라 - // 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다. + // Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다. email: credential.email, ); } @@ -372,7 +371,8 @@ final class KakaoSignInProvider implements SocialAuthProvider { return AuthSucceeded(_toAccount(result.user, provider)); }); - /// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다. + /// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는 + /// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다. Future link(fb.User user) => _run(AuthFlow.link, () async { await _exchange({'accessToken': await _accessToken(), 'link': true}); await user.reload(); @@ -410,8 +410,8 @@ final class KakaoSignInProvider implements SocialAuthProvider { ? details['code'] as String : 'E-AUTH-03'; return AuthFailed(code, switch (code) { - _ when details is Map && details['reason'] == 'different-email' => - '가입하신 이메일과 다른 계정입니다.', + _ when details is Map && details['reason'] == 'session' => + '보안을 위해 다시 로그인한 뒤 시도해 주세요.', 'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', 'E-AUTH-09' => '이미 연결된 계정입니다.', _ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', @@ -594,7 +594,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { 'no-such-provider' => 'E-AUTH-10', // Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다. 'operation-not-allowed' => 'E-AUTH-11', - 'requires-recent-login' => 'E-AUTH-12', + // 현재 계정의 세션이 만료 · 폐기됐다. 다시 로그인해야 한다. + 'requires-recent-login' || + 'user-token-expired' || + 'invalid-user-token' || + 'user-mismatch' || + 'user-disabled' => 'E-AUTH-12', _ => 'E-AUTH-01', }; @@ -634,7 +639,11 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => 'provider-already-linked' => '이미 연결된 계정입니다.', 'no-such-provider' => '연결되어 있지 않은 계정입니다.', 'operation-not-allowed' => '이 로그인 수단은 아직 사용할 수 없습니다.', - 'requires-recent-login' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', + 'requires-recent-login' || + 'user-token-expired' || + 'invalid-user-token' || + 'user-mismatch' || + 'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', _ => '이메일 또는 비밀번호를 확인해 주세요.', }; diff --git a/app/test/auth_kakao_test.dart b/app/test/auth_kakao_test.dart index 2e89df0..997467d 100644 --- a/app/test/auth_kakao_test.dart +++ b/app/test/auth_kakao_test.dart @@ -109,20 +109,31 @@ void main() { expect((outcome as AuthSucceeded).account.uid, 'u-1'); }); - test('다른 이메일이면 E-AUTH-08 · 이메일 안내 문구', () async { + test('다른 계정에 붙은 카카오면 E-AUTH-08 · 세션 만료면 다시 로그인 안내', () async { final auth = MockFirebaseAuth( signedIn: true, mockUser: MockUser(uid: 'u-1'), ); - final outcome = await kakao( + final inUse = await kakao( auth, serverError: FirebaseFunctionsException( code: 'already-exists', message: 'x', - details: {'code': 'E-AUTH-08', 'reason': 'different-email'}, + details: {'code': 'E-AUTH-08'}, ), ).link(auth.currentUser!); - expect((outcome as AuthFailed).code, 'E-AUTH-08'); + expect((inUse as AuthFailed).code, 'E-AUTH-08'); + expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.'); + + final expired = await kakao( + auth, + serverError: FirebaseFunctionsException( + code: 'permission-denied', + message: 'x', + details: {'code': 'E-AUTH-03', 'reason': 'session'}, + ), + ).link(auth.currentUser!); + expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.'); }); }); } diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index 4c9117f..49aa62f 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -127,8 +127,27 @@ void main() { expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); }); - test('다른 이메일의 LINE 계정은 붙였다가 바로 떼고 E-AUTH-08', () async { - final user = _LinkingUser(linkedEmail: 'someone@else.com'); + test('이메일이 다르거나 없는 LINE 계정도 붙는다 — 이메일 일치는 소유 확인이 아니다', () async { + for (final email in ['someone@else.com', null]) { + final user = _LinkingUser(linkedEmail: email); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final linker = FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ); + + final outcome = await linker.link(AuthProviderKind.line); + + expect(outcome, isA(), reason: '$email'); + expect(linker.linkedAccounts.keys, contains(AuthProviderKind.line)); + } + }); + + test('다른 FANUZU 계정에 붙은 LINE 은 Firebase 거부를 E-AUTH-08 로 · 연결되지 않는다', () async { + final user = _LinkingUser( + linkedEmail: null, + error: fb.FirebaseAuthException(code: 'credential-already-in-use'), + ); final auth = MockFirebaseAuth(signedIn: true, mockUser: user); final linker = FirebaseAccountLinker( providers: [line(auth)], @@ -138,14 +157,59 @@ void main() { final outcome = await linker.link(AuthProviderKind.line); expect((outcome as AuthFailed).code, 'E-AUTH-08'); - expect( - linker.linkedAccounts.keys, - isNot(contains(AuthProviderKind.line)), + expect(outcome.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.'); + expect(linker.linkedAccounts.keys, isNot(contains(AuthProviderKind.line))); + }); + + test('Google · Apple(자격증명 경로)도 이메일이 달라도 붙는다', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1', email: 'fan@fanuzu.co.kr'), ); + final outcome = await FirebaseAccountLinker( + providers: [const _FakeCredential('other@gmail.com')], + firebaseAuth: auth, + ).link(AuthProviderKind.google); + expect(outcome, isA()); + }); + + test('세션이 만료됐으면 다시 로그인 안내(E-AUTH-12)', () async { + final user = _LinkingUser( + linkedEmail: null, + error: fb.FirebaseAuthException(code: 'user-token-expired'), + ); + final auth = MockFirebaseAuth(signedIn: true, mockUser: user); + final outcome = await FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ).link(AuthProviderKind.line); + expect((outcome as AuthFailed).code, 'E-AUTH-12'); }); }); } +/// 이메일을 함께 주는 자격증명 제공자(Google 흉내). +final class _FakeCredential implements SocialAuthProvider, CredentialProvider { + const _FakeCredential(this.email); + final String email; + + @override + AuthProviderKind get provider => AuthProviderKind.google; + @override + bool get isLive => true; + @override + List get missingConfig => const []; + @override + Future signIn() async => const AuthCancelled(); + @override + Future signOut() async {} + @override + Future obtainCredential() async => ( + credential: fb.GoogleAuthProvider.credential(idToken: 'id'), + email: email, + ); +} + class _ThrowingAuth extends MockFirebaseAuth { _ThrowingAuth(this.code); final String code; @@ -165,17 +229,19 @@ class _HangingAuth extends MockFirebaseAuth { /// Google 로 가입한 사용자. 연결하면 [linkedEmail] 을 가진 제공자 정보가 붙는다. // ignore: must_be_immutable class _LinkingUser extends MockUser { - _LinkingUser({required this.linkedEmail}) + _LinkingUser({required this.linkedEmail, this.error}) : super( uid: 'u-1', email: 'fan@fanuzu.co.kr', providerData: [_info('google.com', 'fan@fanuzu.co.kr')], ); - final String linkedEmail; + final String? linkedEmail; + final fb.FirebaseAuthException? error; @override Future linkWithProvider(fb.AuthProvider provider) async { + if (error case final e?) throw e; final result = await super.linkWithProvider(provider); providerData ..removeLast() @@ -184,7 +250,7 @@ class _LinkingUser extends MockUser { } } -fb.UserInfo _info(String providerId, String email) => +fb.UserInfo _info(String providerId, String? email) => // ignore: invalid_use_of_protected_member fb.UserInfo.fromJson({ 'uid': 'p-$providerId', From bc197971587f345ecc0d1ba7e2bbf075fe602cc2 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sat, 10 Oct 2026 21:23:15 +0900 Subject: [PATCH 06/19] =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EC=9B=B9=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A4=91=EB=8B=A8=C2=B7=EC=8B=A4?= =?UTF-8?q?=ED=8C=A8=EB=A5=BC=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=8C=80=EC=8B=A0=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EB=AC=B8=EA=B5=AC=EB=A1=9C=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=C2=B7=20=EB=84=A4=ED=8A=B8=EC=9B=8C=ED=81=AC=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=95=88=EB=82=B4=20=C2=B7=20Android=20?= =?UTF-8?q?=EC=A4=91=EB=8B=A8=EB=90=9C=20=EC=9B=B9=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=A0=95=EB=A6=AC=20=ED=9B=84=20=EC=9E=AC=EC=8B=9C?= =?UTF-8?q?=EB=8F=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/android/app/build.gradle.kts | 6 ++ .../kotlin/kr/co/fanuzu/app/MainActivity.kt | 26 +++++- .../auth/firebase_account_linker.dart | 8 +- app/lib/features/auth/login_screen.dart | 4 +- app/lib/features/auth/social_providers.dart | 90 +++++++++++++++---- app/test/auth_error_message_test.dart | 50 ++++++++++- app/test/auth_oidc_test.dart | 60 +++++++++++++ 7 files changed, 219 insertions(+), 25 deletions(-) diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts index 639f9e6..db53af9 100644 --- a/app/android/app/build.gradle.kts +++ b/app/android/app/build.gradle.kts @@ -104,3 +104,9 @@ android { flutter { source = "../.." } + +dependencies { + // MainActivity 의 「버려진 Firebase 웹 로그인 정리」(kr.co.fanuzu.app/auth) — firebase-auth 가 이미 쓰는 + // 라이브러리라 새로 들어오는 것은 없다. 앱 모듈에서 직접 부르려고 컴파일 경로에 올린다. + implementation("androidx.localbroadcastmanager:localbroadcastmanager:1.1.0") +} diff --git a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt index 84b6255..ce1bf8b 100644 --- a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt +++ b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt @@ -1,5 +1,29 @@ package kr.co.fanuzu.app +import android.content.Intent +import androidx.localbroadcastmanager.content.LocalBroadcastManager import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + // 버려진 Firebase 웹 로그인(LINE · Apple OIDC) 정리 — Dart `clearStaleWebSignIn`. + // 인가 중 다른 앱(LINE 본인 확인 등)으로 갔다 돌아오면 Firebase 의 대기 작업이 결과 없이 남아, + // 다음 시도가 `web-context-already-presented` 로 막힌다. Firebase 의 인가 화면이 「닫힘」을 알릴 때 + // 보내는 것과 같은 취소 신호를 보내 대기 작업을 취소로 끝내고 다음 시도를 연다. 대기 작업이 없으면 아무 일도 없다. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/auth") + .setMethodCallHandler { call, result -> + if (call.method != "cancelPendingWebSignIn") { + result.notImplemented() + return@setMethodCallHandler + } + LocalBroadcastManager.getInstance(applicationContext).sendBroadcast( + Intent("com.google.firebase.auth.ACTION_RECEIVE_FIREBASE_AUTH_INTENT") + .putExtra("com.google.firebase.auth.internal.EXTRA_CANCELED", true) + ) + result.success(null) + } + } +} diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index c1f177c..01f3f1b 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -78,7 +78,7 @@ final class FirebaseAccountLinker implements AccountLinker { } on fb.FirebaseAuthException catch (error) { if (isWebFlowCancelled(error)) return const AuthCancelled(); return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.link), authMessage(error, flow: AuthFlow.link), ); } on Object catch (error) { @@ -108,7 +108,7 @@ final class FirebaseAccountLinker implements AccountLinker { return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider)); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.link), authMessage(error, flow: AuthFlow.link), ); } @@ -116,8 +116,8 @@ final class FirebaseAccountLinker implements AccountLinker { /// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { - final result = await untilAbandoned( - user.linkWithProvider(impl.authProvider), + final result = await retryStaleWebContext( + () => untilAbandoned(user.linkWithProvider(impl.authProvider)), ); return AuthSucceeded(_accountOf(result.user, impl.provider)); } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index f714f75..18b530f 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -369,9 +369,11 @@ const _names = { AuthProviderKind.email: '이메일로', }; +/// 로그인 결과 안내. 취소 · 중단도 안내한다 — 로그인 성공으로 처리하지 않고 다시 시도하게 한다. +/// 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다. String? authNotice(AuthOutcome outcome) => switch (outcome) { AuthSucceeded() => null, - AuthCancelled() => null, + AuthCancelled() => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.', AuthFailed(:final code, :final message) => '[$code] $message', AuthNotConfigured(:final provider, :final missing) => '${provider.shortName} 로그인은 아직 준비 중입니다.\n' diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index a2671e7..42db9e5 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -90,7 +90,7 @@ final class GoogleSignInProvider return AuthFailed('E-AUTH-03', error.description ?? error.code.name); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } @@ -188,7 +188,7 @@ final class AppleSignInProvider return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } @@ -271,16 +271,18 @@ final class OidcSignInProvider implements SocialAuthProvider { final missing = missingConfig; if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); try { - final result = await untilAbandoned( - (firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider( - authProvider, + final result = await retryStaleWebContext( + () => untilAbandoned( + (firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider( + authProvider, + ), ), ); return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { if (isWebFlowCancelled(error)) return const AuthCancelled(); return AuthFailed( - authCatalogCode(error), + authCatalogCode(error, flow: AuthFlow.signIn), authMessage(error, flow: AuthFlow.signIn), ); } @@ -417,7 +419,10 @@ final class KakaoSignInProvider implements SocialAuthProvider { _ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', }); } on fb.FirebaseAuthException catch (error) { - return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow)); + return AuthFailed( + authCatalogCode(error, flow: flow), + authMessage(error, flow: flow), + ); } } @@ -435,9 +440,39 @@ final class KakaoSignInProvider implements SocialAuthProvider { } /// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. +/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름). bool isWebFlowCancelled(fb.FirebaseAuthException error) => error.code == 'web-context-canceled' || - error.code == 'web-context-cancelled'; + error.code == 'web-context-cancelled' || + error.code == 'user-cancelled'; + +const _authChannel = MethodChannel('kr.co.fanuzu.app/auth'); + +/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`). +/// +/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을 +/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(다시 눌러도 로그인이 안 됨). +/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다. +Future clearStaleWebSignIn() async { + if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return; + try { + await _authChannel.invokeMethod('cancelPendingWebSignIn'); + } on Object catch (error) { + debugPrint('[Auth] clear stale web sign-in skipped: $error'); + } +} + +/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다. +/// 취소 · 실패 뒤에도 정상적으로 다시 시도할 수 있어야 한다. +Future retryStaleWebContext(Future Function() attempt) async { + try { + return await attempt(); + } on fb.FirebaseAuthException catch (error) { + if (error.code != 'web-context-already-presented') rethrow; + await clearStaleWebSignIn(); + return attempt(); + } +} /// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다. /// @@ -462,6 +497,8 @@ Future untilAbandoned( if (state != AppLifecycleState.resumed) return; timer = Timer(grace, () { if (done.isCompleted) return; + // Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다. + unawaited(clearStaleWebSignIn()); done.completeError( fb.FirebaseAuthException(code: 'web-context-canceled'), ); @@ -550,8 +587,8 @@ final class EmailPasswordProvider implements EmailAuthProvider { return const AuthCancelled(); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), - authMessage(error, flow: AuthFlow.signIn), + authCatalogCode(error, flow: AuthFlow.email), + authMessage(error, flow: AuthFlow.email), ); } } @@ -564,8 +601,8 @@ final class EmailPasswordProvider implements EmailAuthProvider { return AuthSucceeded(_toAccount(result.user, provider)); } on fb.FirebaseAuthException catch (error) { return AuthFailed( - authCatalogCode(error), - authMessage(error, flow: AuthFlow.signIn), + authCatalogCode(error, flow: AuthFlow.email), + authMessage(error, flow: AuthFlow.email), ); } } @@ -580,7 +617,14 @@ final class EmailPasswordProvider implements EmailAuthProvider { /// /// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호 /// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다. -String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { +/// +/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은 +/// 소셜 실패 E-AUTH-03 이다. 하나로 E-AUTH-01 이면 LINE 웹 로그인이 끊길 때(`web-*`) +/// 「이메일 또는 비밀번호를 확인해 주세요」가 뜬다. +String authCatalogCode( + fb.FirebaseAuthException error, { + required AuthFlow flow, +}) => switch (error.code) { 'email-already-in-use' => 'E-AUTH-04', 'weak-password' => 'E-AUTH-05', 'invalid-email' => 'E-AUTH-06', @@ -600,7 +644,7 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { 'invalid-user-token' || 'user-mismatch' || 'user-disabled' => 'E-AUTH-12', - _ => 'E-AUTH-01', + _ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03', }; /// 오류 문구가 갈리는 경로. @@ -609,9 +653,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) { /// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지 /// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다. enum AuthFlow { - /// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`). + /// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`). signIn, + /// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다. + email, + /// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로 /// (`linkWithCredential` · `unlink`) — 별첨 B v1.4. link, @@ -631,7 +678,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => 'invalid-email' => '이메일 형식이 올바르지 않습니다.', 'too-many-requests' => '잠시 후 다시 시도해 주세요.', 'account-exists-with-different-credential' => switch (flow) { - AuthFlow.signIn => _otherProviderSignInGuide(error.email), + AuthFlow.signIn || AuthFlow.email => + _otherProviderSignInGuide(error.email), AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', }, // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. @@ -644,7 +692,15 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => 'invalid-user-token' || 'user-mismatch' || 'user-disabled' => '보안을 위해 다시 로그인한 뒤 시도해 주세요.', - _ => '이메일 또는 비밀번호를 확인해 주세요.', + // 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다. + 'web-context-already-presented' => '로그인이 완료되지 않았습니다. 다시 시도해 주세요.', + 'web-network-request-failed' || + 'network-request-failed' => '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + _ => switch (flow) { + AuthFlow.email => '이메일 또는 비밀번호를 확인해 주세요.', + AuthFlow.signIn => '소셜 로그인에 실패했습니다.', + AuthFlow.link => '계정 연결에 실패했습니다.', + }, }; /// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내. diff --git a/app/test/auth_error_message_test.dart b/app/test/auth_error_message_test.dart index 8fb61bc..89a957b 100644 --- a/app/test/auth_error_message_test.dart +++ b/app/test/auth_error_message_test.dart @@ -1,3 +1,5 @@ +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/login_screen.dart'; import 'package:fancake/features/auth/social_providers.dart'; import 'package:firebase_auth/firebase_auth.dart' as fb; import 'package:flutter_test/flutter_test.dart'; @@ -61,7 +63,10 @@ void main() { test('에러 카탈로그 ID 는 경로와 무관하게 E-AUTH-08 이다', () { // 문구만 갈랐다. 카탈로그를 손대면 Admin·QA 의 코드 대조가 어긋난다. expect( - authCatalogCode(_exception('account-exists-with-different-credential')), + authCatalogCode( + _exception('account-exists-with-different-credential'), + flow: AuthFlow.signIn, + ), 'E-AUTH-08', ); }); @@ -85,13 +90,54 @@ void main() { // 「등록되지 않은 이메일」과 「비밀번호 오류」가 같은 문구로 나가야 한다. for (final code in ['user-not-found', 'wrong-password']) { expect( - authMessage(_exception(code), flow: AuthFlow.signIn), + authMessage(_exception(code), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.', reason: code, ); } }); }); + + group('소셜 웹 로그인 실패 · 중단 — 이메일 문구를 쓰지 않는다', () { + const webCodes = [ + 'web-context-already-presented', + 'web-network-request-failed', + 'web-internal-error', + 'internal-error', + 'invalid-credential', + ]; + + test('소셜 로그인 · 연결의 알 수 없는 실패는 E-AUTH-03 · 소셜 문구', () { + for (final code in webCodes) { + for (final flow in [AuthFlow.signIn, AuthFlow.link]) { + expect(authCatalogCode(_exception(code), flow: flow), 'E-AUTH-03', reason: '$code $flow'); + expect( + authMessage(_exception(code), flow: flow), + isNot('이메일 또는 비밀번호를 확인해 주세요.'), + reason: '$code $flow', + ); + } + } + expect( + authMessage(_exception('web-context-already-presented'), flow: AuthFlow.signIn), + '로그인이 완료되지 않았습니다. 다시 시도해 주세요.', + ); + expect( + authMessage(_exception('web-network-request-failed'), flow: AuthFlow.signIn), + '네트워크 연결을 확인한 뒤 다시 시도해 주세요.', + ); + expect(authMessage(_exception('internal-error'), flow: AuthFlow.signIn), '소셜 로그인에 실패했습니다.'); + }); + + test('이메일 폼만 E-AUTH-01 · 「이메일 또는 비밀번호」', () { + expect(authCatalogCode(_exception('internal-error'), flow: AuthFlow.email), 'E-AUTH-01'); + expect(authMessage(_exception('internal-error'), flow: AuthFlow.email), '이메일 또는 비밀번호를 확인해 주세요.'); + }); + + test('취소는 성공이 아니고 다시 시도하라고 안내한다', () { + expect(authNotice(const AuthCancelled()), '로그인이 완료되지 않았습니다. 다시 시도해 주세요.'); + }); + }); } /// `FirebaseAuthException` 의 생성자가 `@protected` 라 밖에서 직접 부르면 diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index 49aa62f..6094fe1 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -8,6 +8,7 @@ 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/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -112,6 +113,52 @@ void main() { expect(outcome, isA()); }); + group('웹 흐름 중단 뒤 재시도', () { + late List cleared; + setUp(() { + cleared = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), (call) async { + cleared.add(call.method); + return null; + }); + }); + tearDown(() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(const MethodChannel('kr.co.fanuzu.app/auth'), null)); + + test('앞선 흐름이 남아 막히면(web-context-already-presented) 정리하고 한 번 다시 열어 로그인된다', () async { + final auth = _StaleThenOkAuth(); + final outcome = await line(auth).signIn(); + expect(outcome, isA()); + expect(auth.calls, 2); + expect(cleared, ['cancelPendingWebSignIn']); + }); + + test('정리 뒤에도 막히면 소셜 문구로 실패 — 「이메일 또는 비밀번호」가 아니다', () async { + final outcome = await line(_ThrowingAuth('web-context-already-presented')).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(outcome.message, '로그인이 완료되지 않았습니다. 다시 시도해 주세요.'); + }); + + test('망 오류도 소셜 경로 문구', () async { + final outcome = await line(_ThrowingAuth('web-network-request-failed')).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(outcome.message, '네트워크 연결을 확인한 뒤 다시 시도해 주세요.'); + }); + + testWidgets('Android: 결과 없이 돌아와 취소로 풀릴 때 Firebase 대기도 정리한다', (tester) async { + late AuthOutcome outcome; + line(_HangingAuth()).signIn().then((o) => outcome = o); + for (final s in [AppLifecycleState.inactive, AppLifecycleState.hidden, AppLifecycleState.paused, + AppLifecycleState.hidden, AppLifecycleState.inactive, AppLifecycleState.resumed]) { + tester.binding.handleAppLifecycleStateChanged(s); + } + await tester.pump(const Duration(seconds: 9)); + expect(outcome, isA()); + expect(cleared, ['cancelPendingWebSignIn']); + }); + }); + group('계정 연결', () { test('같은 UID 에 oidc.line 을 붙인다', () async { final user = _LinkingUser(linkedEmail: 'fan@fanuzu.co.kr'); @@ -219,6 +266,19 @@ class _ThrowingAuth extends MockFirebaseAuth { throw fb.FirebaseAuthException(code: code); } +/// 앞선 웹 흐름이 남아 첫 시도가 막히는 Firebase(Android `web-context-already-presented`). +class _StaleThenOkAuth extends MockFirebaseAuth { + _StaleThenOkAuth() : super(mockUser: MockUser(uid: 'line-1')); + var calls = 0; + + @override + Future signInWithProvider(fb.AuthProvider provider) { + calls += 1; + if (calls == 1) throw fb.FirebaseAuthException(code: 'web-context-already-presented'); + return super.signInWithProvider(provider); + } +} + /// 콜백을 잃어 영영 끝나지 않는 Firebase 대기 Task. class _HangingAuth extends MockFirebaseAuth { @override From 897dff950d5147821fe6ee2e5aae55c5930f0bd5 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 11 Oct 2026 00:29:44 +0900 Subject: [PATCH 07/19] =?UTF-8?q?MY=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83(=ED=99=95=EC=9D=B8=20?= =?UTF-8?q?=ED=9B=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=B5=EA=B7=80)=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/core/router/app_router.dart | 2 +- app/lib/core/ui/tab_placeholder.dart | 8 ++++++ app/lib/features/my/my_screen.dart | 37 ++++++++++++++++++++++++++-- app/test/my_logout_test.dart | 29 ++++++++++++++++++++++ 4 files changed, 73 insertions(+), 3 deletions(-) create mode 100644 app/test/my_logout_test.dart diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index 21aa657..5ca939e 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -136,7 +136,7 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { routes: [ GoRoute( path: appTabs[4].path, - builder: (_, _) => const MyScreen(), + builder: (_, _) => MyScreen(auth: auth), ), ], ), diff --git a/app/lib/core/ui/tab_placeholder.dart b/app/lib/core/ui/tab_placeholder.dart index 92b0570..dba69c6 100644 --- a/app/lib/core/ui/tab_placeholder.dart +++ b/app/lib/core/ui/tab_placeholder.dart @@ -13,6 +13,7 @@ class TabPlaceholder extends StatelessWidget { required this.tab, required this.screenId, required this.screenName, + this.footer, }); /// 계약 고정 탭 라벨(견적서 v2.1 5탭). @@ -20,6 +21,9 @@ class TabPlaceholder extends StatelessWidget { final String screenId; final String screenName; + /// 자리표시 아래에 붙는 동작(예: MY 의 로그아웃). 없으면 그리지 않는다. + final Widget? footer; + @override Widget build(BuildContext context) { final text = Theme.of(context).textTheme; @@ -55,6 +59,10 @@ class TabPlaceholder extends StatelessWidget { ], ), ), + if (footer != null) ...[ + const SizedBox(height: AppSpace.s5), + footer!, + ], ], ), ), diff --git a/app/lib/features/my/my_screen.dart b/app/lib/features/my/my_screen.dart index 1623342..4e406d9 100644 --- a/app/lib/features/my/my_screen.dart +++ b/app/lib/features/my/my_screen.dart @@ -1,15 +1,48 @@ import 'package:flutter/material.dart'; import '../../core/ui/tab_placeholder.dart'; +import '../auth/auth_repository.dart'; /// MY 탭 루트 — S-M-HOME 마이 페이지. +/// +/// 화면 구현 전이라도 로그아웃은 여기서 할 수 있어야 한다 — 다른 계정 · 다른 로그인 +/// 방법으로 다시 들어가 볼 길이 없으면 소셜 로그인 4종을 한 기기에서 확인할 수 없다. +/// 로그아웃하면 인증 상태가 바뀌고 라우터가 로그인 화면으로 보낸다. class MyScreen extends StatelessWidget { - const MyScreen({super.key}); + const MyScreen({super.key, required this.auth}); + + final AuthRepository auth; + + Future _logout(BuildContext context) async { + final ok = await showDialog( + context: context, + builder: (context) => AlertDialog( + content: const Text('로그아웃 하시겠습니까?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('취소'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('로그아웃'), + ), + ], + ), + ); + if (ok == true) await auth.signOut(); + } @override - Widget build(BuildContext context) => const TabPlaceholder( + Widget build(BuildContext context) => TabPlaceholder( tab: 'MY', screenId: 'S-M-HOME', screenName: '마이 페이지', + footer: Builder( + builder: (context) => OutlinedButton( + onPressed: () => _logout(context), + child: const Text('로그아웃'), + ), + ), ); } diff --git a/app/test/my_logout_test.dart b/app/test/my_logout_test.dart new file mode 100644 index 0000000..f3744f4 --- /dev/null +++ b/app/test/my_logout_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:fancake/app.dart'; +import 'package:fancake/features/auth/auth_repository.dart'; + +/// MY 에서 로그아웃하면 로그인 화면으로 돌아간다 — 한 기기에서 로그인 방법을 바꿔 확인할 수 있어야 한다. +void main() { + testWidgets('MY → 로그아웃 → 확인 → 로그인 화면, 취소면 그대로', (tester) async { + final auth = AuthRepository(releaseMode: false)..signInWithMockSession(); + await tester.pumpWidget(FancakeApp(auth: auth)); + await tester.pumpAndSettle(); + await tester.tap(find.text('MY')); + await tester.pumpAndSettle(); + + await tester.tap(find.widgetWithText(OutlinedButton, '로그아웃')); + await tester.pumpAndSettle(); + await tester.tap(find.text('취소')); + await tester.pumpAndSettle(); + expect(auth.isSignedIn, isTrue); + + await tester.tap(find.widgetWithText(OutlinedButton, '로그아웃')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); + await tester.pumpAndSettle(); + expect(auth.isSignedIn, isFalse); + expect(find.byType(NavigationBar), findsNothing); + }); +} From cef5436336d7b5695db47dd1e7555c886a609ba4 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 11 Oct 2026 00:35:21 +0900 Subject: [PATCH 08/19] =?UTF-8?q?1=EC=B0=A8=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B2=84=EC=A0=84=200.1.1+11?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index bacda37..97a0ed2 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: fancake description: "FANUZU — 팬덤 플랫폼 앱 (골격). 발주사 (주)팬케이크 / 수행사 스위치 주식회사" publish_to: 'none' -version: 0.1.0+9 +version: 0.1.1+11 environment: sdk: ^3.11.5 From e440251c78a46d1720c41089daf469a5be25efab Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 11 Oct 2026 00:56:08 +0900 Subject: [PATCH 09/19] =?UTF-8?q?iOS=20=EC=97=90=EC=84=9C=EB=8F=84=20LINE?= =?UTF-8?q?=20=EC=95=B1=20=EC=9E=90=EB=8F=99=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20=EA=BA=BC=20=EC=9B=B9=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=B4=20=EB=81=8A=EA=B8=B0=EC=A7=80=20=EC=95=8A=EA=B2=8C=20?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/auth/auth_repository.dart | 9 ++++++--- app/lib/features/auth/social_providers.dart | 16 +++++++--------- app/test/auth_oidc_test.dart | 12 +++++++++--- 3 files changed, 22 insertions(+), 15 deletions(-) diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index a25591f..8c2cd2d 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -68,9 +68,12 @@ class AuthRepository extends ChangeNotifier { AuthProviderKind.line, providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!, scopes: const ['profile', 'email'], - // LINE 앱 자동 로그인으로 넘어가면 콜백이 Custom Tab 밖에서 열려 로그인이 - // 깨진다(Android). LINE Login 공식 파라미터로 끈다. - androidParameters: const {'disable_auto_login': 'true'}, + // LINE 앱 자동 로그인으로 넘어가면 콜백이 웹 로그인 창 밖에서 열려 로그인이 + // 깨진다(Android · iOS). LINE Login 공식 파라미터로 끈다. + appParameters: const { + 'disable_auto_login': 'true', + 'disable_ios_auto_login': 'true', + }, ), ]; diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 42db9e5..1632fcb 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -224,7 +224,7 @@ final class OidcSignInProvider implements SocialAuthProvider { this.provider, { required this.providerId, this.scopes = const [], - this.androidParameters = const {}, + this.appParameters = const {}, this.firebaseAuth, }); @@ -239,11 +239,11 @@ final class OidcSignInProvider implements SocialAuthProvider { /// KOE205 로 로그인 자체를 막는다. final List scopes; - /// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로 - /// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가 - /// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다. - /// iOS 는 정상이라 건드리지 않는다. - final Map androidParameters; + /// 앱(Android · iOS)에서 인가 URL 에 붙일 파라미터. 웹 로그인 도중 제공자 앱(LINE 등)으로 + /// 넘어갔다 돌아오면 콜백이 원래 브라우저 세션 밖에서 열려 Firebase 핸들러가 + /// 「missing initial state」로 끝난다(Android 10/5 · iOS 10/11 실기기) — 앱 전환을 막는 + /// 제공자 공식 파라미터를 여기 둔다. + final Map appParameters; /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. final fb.FirebaseAuth? firebaseAuth; @@ -260,9 +260,7 @@ final class OidcSignInProvider implements SocialAuthProvider { for (final scope in scopes) { p.addScope(scope); } - if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) { - p.setCustomParameters(androidParameters); - } + if (!kIsWeb) p.setCustomParameters(appParameters); return p; } diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index 6094fe1..bd3aa67 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -87,16 +87,22 @@ void main() { expect((outcome as AuthFailed).code, 'E-AUTH-11'); }); - test('Android 의 LINE 은 앱 자동 로그인을 끈다 · iOS 는 파라미터 없음', () { + test('LINE 은 Android · iOS 모두 앱 자동 로그인을 끈다', () { final line = AuthRepository().providers.firstWhere( (p) => p.provider == AuthProviderKind.line, ) as OidcSignInProvider; - expect(line.authProvider.parameters, {'disable_auto_login': 'true'}); + expect(line.authProvider.parameters, { + 'disable_auto_login': 'true', + 'disable_ios_auto_login': 'true', + }); debugDefaultTargetPlatformOverride = TargetPlatform.iOS; addTearDown(() => debugDefaultTargetPlatformOverride = null); - expect(line.authProvider.parameters, isEmpty); + expect(line.authProvider.parameters, { + 'disable_auto_login': 'true', + 'disable_ios_auto_login': 'true', + }); }); testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async { From 9119b3beaa01e1f1672d86ed3afd70a21cd9dfeb Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 11 Oct 2026 01:29:23 +0900 Subject: [PATCH 10/19] =?UTF-8?q?LINE=20=EB=A1=9C=EA=B7=B8=EC=9D=B8?= =?UTF-8?q?=EC=9D=84=20LINE=20SDK=EC=99=80=20=EC=84=9C=EB=B2=84=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=ED=86=A0=ED=81=B0=20=EB=B0=A9=EC=8B=9D?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=E2=80=94=20iOS=C2=B7?= =?UTF-8?q?Android=20=EC=9B=B9=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=A4=91?= =?UTF-8?q?=EB=8B=A8=20=EB=AC=B8=EC=A0=9C=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/BUILD.md | 9 +- app/config/kakao.env.example | 1 + app/firebase/functions/src/account/account.ts | 2 +- app/firebase/functions/src/account/kakao.ts | 93 +-------- app/firebase/functions/src/account/line.ts | 88 +++++++++ .../functions/src/account/social_token.ts | 127 ++++++++++++ app/firebase/functions/src/index.ts | 1 + app/firebase/functions/test/auth_line.test.ts | 146 ++++++++++++++ .../xcshareddata/swiftpm/Package.resolved | 9 + .../xcshareddata/swiftpm/Package.resolved | 9 + app/ios/Runner/Info.plist | 13 ++ app/lib/features/auth/auth_repository.dart | 19 +- .../auth/firebase_account_linker.dart | 8 +- app/lib/features/auth/social_providers.dart | 182 +++++++++++++++--- app/pubspec.lock | 8 + app/pubspec.yaml | 5 + app/test/auth_line_test.dart | 171 ++++++++++++++++ app/test/auth_oidc_test.dart | 31 +-- 18 files changed, 758 insertions(+), 164 deletions(-) create mode 100644 app/firebase/functions/src/account/line.ts create mode 100644 app/firebase/functions/src/account/social_token.ts create mode 100644 app/firebase/functions/test/auth_line.test.ts create mode 100644 app/test/auth_line_test.dart diff --git a/app/BUILD.md b/app/BUILD.md index b40ff73..23222f5 100644 --- a/app/BUILD.md +++ b/app/BUILD.md @@ -67,11 +67,10 @@ Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받 fvm flutter build apk --release --dart-define-from-file=config/kakao.env ``` -**LINE** 은 Firebase Authentication 의 OpenID Connect 제공자 `oidc.line` 이다. 앱에 넣을 값은 없고, -콘솔 Authentication → 로그인 방법에 LINE Login 채널 ID · 채널 시크릿 · issuer -`https://access.line.me` 를 등록하고, LINE Developers 콘솔의 Callback URL 에 -`https://<프로젝트 ID>.firebaseapp.com/__/auth/handler` 를 넣는다. 콘솔에서 켜지 않았으면 로그인이 -E-AUTH-11 로 끝난다. +**LINE 채널 ID** — 같은 `config/kakao.env` 에 `FANUZU_LINE_CHANNEL_ID=`. Dart 만 읽는다 +(iOS 복귀 스킴은 `line3rdp.<번들 ID>` 라 키가 필요 없다). 없으면 LINE 로그인만 「설정 전」으로 막힌다. 서버 +`fnAuthLine` 은 같은 값을 `firebase/functions/.env.<프로젝트>` 의 `LINE_CHANNEL_ID` 로 받는다(저장소 밖). +LINE Developers 콘솔의 LINE Login 채널에 Android 패키지명 · iOS 번들 ID 를 등록한다. ## Android Apple 로그인 — 앱에 넣을 값 없음 diff --git a/app/config/kakao.env.example b/app/config/kakao.env.example index f00bbf5..e580efe 100644 --- a/app/config/kakao.env.example +++ b/app/config/kakao.env.example @@ -1 +1,2 @@ FANUZU_KAKAO_NATIVE_APP_KEY= +FANUZU_LINE_CHANNEL_ID= diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 72bfd28..953c049 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -22,7 +22,7 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => { const walletRef = db.doc(`users/${user.uid}/wallet/main`); // 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다. // Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는 - // 계정을 만든 뒤에 연결 정보가 붙으므로 `fnAuthKakao` 가 따로 쓴다. + // 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다. const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null; const defaults: Record = { uid: user.uid, diff --git a/app/firebase/functions/src/account/kakao.ts b/app/firebase/functions/src/account/kakao.ts index e41b8f9..be98533 100644 --- a/app/firebase/functions/src/account/kakao.ts +++ b/app/firebase/functions/src/account/kakao.ts @@ -1,10 +1,9 @@ -import { getAuth } from 'firebase-admin/auth'; -import { getFirestore } from 'firebase-admin/firestore'; import { defineString } from 'firebase-functions/params'; import { onCall } from 'firebase-functions/v2/https'; import { str } from '../core/callable.js'; import { ERR, fail } from '../core/errors.js'; +import { exchangeSocial, type SocialSpec } from './social_token.js'; /** * `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다. @@ -21,6 +20,7 @@ import { ERR, fail } from '../core/errors.js'; * - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고, * - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다. * 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명). + * UID 찾기 · SNS 연동 규칙은 LINE 과 같아 `social_token.ts` 에 있다. * * 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고 * `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다. @@ -74,92 +74,9 @@ export async function verifyKakaoToken(accessToken: string, appId: string): Prom }; } -/** 이 카카오 계정이 붙은 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] 에 카카오를 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다. - * - * 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다. - * 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 카카오 계정도 연결된다). - * - 새 Provider 자격: [verifyKakaoToken] 이 우리 앱에서 발급된 유효 토큰인지 카카오 API 로 본다. - * - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이 - * 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」. - * - 그 카카오 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도 - * (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다. - */ -export async function linkKakao(uid: string, p: KakaoProfile, authTime?: number): Promise { - const owner = await ownerOf(p.id); - if (owner === uid) return; - if (owner) fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); - const user = await getAuth().getUser(uid); - const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0; - if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) { - fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' }); - } - if (user.providerData.some((d) => d.providerId === KAKAO_PROVIDER_ID)) { - fail(ERR.authAlreadyLinked, '이미 다른 카카오 계정이 연결되어 있습니다.'); - } - try { - await getAuth().updateUser(uid, { providerToLink: linkInfo(p) }); - } catch (e) { - const code = (e as { code?: string }).code ?? ''; - if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) { - fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); - } - throw e; - } - if ((await ownerOf(p.id)) !== uid) { - fail(ERR.authCredentialInUse, '이미 다른 계정에 연결된 카카오 계정입니다.'); - } -} +const KAKAO: SocialSpec = { providerId: KAKAO_PROVIDER_ID, uidPrefix: 'kakao', label: '카카오' }; 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, '로그인이 필요합니다.'); - const authTime = req.auth?.token?.auth_time; - await linkKakao(uid, profile, typeof authTime === 'number' ? authTime : undefined); - return { linked: true }; - } - const uid = await resolveKakaoUid(profile); - // 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이 - // 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다. - if (profile.email) { - await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true }); - } - return { token: await getAuth().createCustomToken(uid) }; + const p = await verifyKakaoToken(str(req.data, 'accessToken'), kakaoAppId.value()); + return exchangeSocial(KAKAO, { id: p.id, displayName: p.nickname, email: p.email }, req); }); diff --git a/app/firebase/functions/src/account/line.ts b/app/firebase/functions/src/account/line.ts new file mode 100644 index 0000000..ee83dd0 --- /dev/null +++ b/app/firebase/functions/src/account/line.ts @@ -0,0 +1,88 @@ +import { defineString } from 'firebase-functions/params'; +import { onCall } from 'firebase-functions/v2/https'; + +import { optionalStr } from '../core/callable.js'; +import { ERR, fail } from '../core/errors.js'; +import { exchangeSocial, type SocialProfile, type SocialSpec } from './social_token.js'; + +/** + * `fn-auth-line` — LINE 로그인 · SNS 연동을 Firebase 커스텀 토큰으로 잇는다. 카카오(`fnAuthKakao`)와 같은 구조다. + * + * **왜 OIDC 가 아니라 이 경로인가.** iOS 에서 Firebase OIDC(`oidc.line`) 웹 흐름은 첫 시도에 Firebase + * 핸들러가 「missing initial state」로 끝난다(`disable_ios_auto_login` 을 붙여도 같다). 앱이 LINE 공식 + * SDK 로 로그인하고 서버가 그 토큰을 LINE API 로 검증해 커스텀 토큰을 내준다. + * + * **사용자 식별은 기존 `oidc.line` 연결을 그대로 쓴다** — OIDC 시절 가입자는 같은 UID 로 들어오고, SNS 연동 + * 화면이 지금처럼 「연결됨」을 그린다. 신규 가입자의 UID 는 `line:` 다. + * + * 입력: `{ idToken?, accessToken?, link? }` — 둘 중 하나는 있어야 한다. ID 토큰이 있으면 그것으로 검증한다 + * (이메일은 ID 토큰에만 온다). `link: true` 면 로그인한 호출자 UID 에 LINE 을 붙이고 `{ linked: true }`, + * 아니면 `{ token }`(커스텀 토큰)을 돌려준다. + */ + +export const LINE_PROVIDER_ID = 'oidc.line'; + +/** LINE Login 채널 ID(숫자). 비밀값이 아니다 — 토큰이 우리 채널에서 발급됐는지 본다. */ +const lineChannelId = defineString('LINE_CHANNEL_ID', { default: '' }); + +const LINE: SocialSpec = { providerId: LINE_PROVIDER_ID, uidPrefix: 'line', label: 'LINE' }; + +export const lineUidOf = (lineId: string) => `${LINE.uidPrefix}:${lineId}`; + +const API = 'https://api.line.me'; + +/** + * LINE 토큰을 LINE API 로 검증한다. 남의 채널에서 받은 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을 + * 막는 지점이다. + * + * - ID 토큰: `POST /oauth2/v2.1/verify` — 서명 · 만료 · `client_id`(우리 채널) 검증을 LINE 이 하고 `sub` · + * `name` · `email`(동의했을 때만)을 돌려준다. + * - 액세스 토큰: `GET /oauth2/v2.1/verify` 로 우리 채널 발급 · 남은 유효 시간을 보고 `/v2/profile` 로 회원번호를 읽는다. + */ +export async function verifyLineToken( + token: { idToken?: string | undefined; accessToken?: string | undefined }, + channelId: string, +): Promise { + if (!channelId) fail(ERR.authFailed, 'LINE 로그인 설정이 끝나지 않았습니다.'); + const bad = (): never => fail(ERR.authFailed, 'LINE 인증을 확인하지 못했습니다.'); + const json = async (res: Response) => { + if (!res.ok) bad(); + return (await res.json()) as Record; + }; + + if (token.idToken) { + const claims = await json(await fetch(`${API}/oauth2/v2.1/verify`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ id_token: token.idToken, client_id: channelId }).toString(), + })); + if (String(claims.aud) !== channelId || typeof claims.sub !== 'string' || !claims.sub) bad(); + return { + id: claims.sub as string, + ...(typeof claims.name === 'string' && claims.name ? { displayName: claims.name } : {}), + ...(typeof claims.email === 'string' && claims.email ? { email: claims.email } : {}), + }; + } + + if (!token.accessToken) fail(ERR.authFailed, 'LINE 인증 정보가 없습니다.'); + const info = await json(await fetch( + `${API}/oauth2/v2.1/verify?access_token=${encodeURIComponent(token.accessToken)}`, + )); + if (String(info.client_id) !== channelId || !(Number(info.expires_in) > 0)) bad(); + const me = await json(await fetch(`${API}/v2/profile`, { + headers: { Authorization: `Bearer ${token.accessToken}` }, + })); + if (typeof me.userId !== 'string' || !me.userId) bad(); + return { + id: me.userId as string, + ...(typeof me.displayName === 'string' && me.displayName ? { displayName: me.displayName } : {}), + }; +} + +export const fnAuthLine = onCall(async (req) => { + const profile = await verifyLineToken( + { idToken: optionalStr(req.data, 'idToken'), accessToken: optionalStr(req.data, 'accessToken') }, + lineChannelId.value(), + ); + return exchangeSocial(LINE, profile, req); +}); diff --git a/app/firebase/functions/src/account/social_token.ts b/app/firebase/functions/src/account/social_token.ts new file mode 100644 index 0000000..2473311 --- /dev/null +++ b/app/firebase/functions/src/account/social_token.ts @@ -0,0 +1,127 @@ +import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; + +import { ERR, fail } from '../core/errors.js'; + +/** + * 공식 SDK 로그인 + 서버 커스텀 토큰 제공자(카카오 `fnAuthKakao` · LINE `fnAuthLine`)의 공통 부분 — + * 검증된 제공자 계정으로 로그인할 UID 를 찾거나 만들고, SNS 연동으로 붙인다. + * + * **사용자 식별은 OIDC 시절 제공자 ID(`oidc.kakao` · `oidc.line`)를 그대로 쓴다.** Firebase 사용자의 + * 제공자 정보에 그 ID / 제공자 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다. + * OIDC 시절 가입자는 같은 UID 로 로그인되고, SNS 연동 화면이 지금처럼 「연결됨」을 그린다. + */ +export interface SocialSpec { + /** Firebase 제공자 ID — `oidc.kakao` · `oidc.line`. */ + providerId: string; + /** 신규 가입자 UID 접두 — `kakao` · `line`. UID 는 `<접두>:<회원번호>`. */ + uidPrefix: string; + /** 오류 문구에 쓰는 이름 — 「카카오」 · 「LINE」. */ + label: string; +} + +export interface SocialProfile { + /** 제공자 회원번호(OIDC `sub`). */ + id: string; + displayName?: string | undefined; + /** 동의했고 제공자가 준 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */ + email?: string | undefined; +} + +/** 이 제공자 계정이 붙은 Firebase UID. 없으면 null. */ +async function ownerOf(spec: SocialSpec, id: string): Promise { + try { + return (await getAuth().getUserByProviderUid(spec.providerId, id)).uid; + } catch (e) { + if ((e as { code?: string }).code === 'auth/user-not-found') return null; + throw e; + } +} + +function linkInfo(spec: SocialSpec, p: SocialProfile) { + return { + providerId: spec.providerId, + uid: p.id, + ...(p.displayName ? { displayName: p.displayName } : {}), + ...(p.email ? { email: p.email } : {}), + }; +} + +/** 로그인할 UID — 기존 연결이 있으면 그 UID, 없으면 `<접두>:` 를 만들고 연결한다. */ +export async function resolveSocialUid(spec: SocialSpec, p: SocialProfile): Promise { + const existing = await ownerOf(spec, p.id); + if (existing) return existing; + const uid = `${spec.uidPrefix}:${p.id}`; + const auth = getAuth(); + try { + await auth.createUser({ uid, ...(p.displayName ? { displayName: p.displayName } : {}) }); + } catch (e) { + // 같은 사람의 첫 로그인이 겹쳤다. 먼저 만든 쪽을 쓴다. + if ((e as { code?: string }).code !== 'auth/uid-already-exists') throw e; + } + await auth.updateUser(uid, { providerToLink: linkInfo(spec, p) }); + return uid; +} + +/** + * SNS 연동 — 로그인한 [uid] 에 제공자 계정을 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다. + * + * 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다. + * 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 계정도 연결된다). + * - 새 Provider 자격: 호출부가 제공자 API 로 우리 앱(채널)에서 발급된 유효 토큰인지 먼저 본다. + * - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이 + * 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」. + * - 그 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도 + * (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다. + */ +export async function linkSocial( + spec: SocialSpec, uid: string, p: SocialProfile, authTime?: number, +): Promise { + const inUse = `이미 다른 계정에 연결된 ${spec.label} 계정입니다.`; + const owner = await ownerOf(spec, p.id); + if (owner === uid) return; + if (owner) fail(ERR.authCredentialInUse, inUse); + const user = await getAuth().getUser(uid); + const revokedAt = user.tokensValidAfterTime ? Date.parse(user.tokensValidAfterTime) : 0; + if (user.disabled || (authTime !== undefined && authTime * 1000 < revokedAt)) { + fail(ERR.authFailed, '로그인이 만료되었습니다. 다시 로그인한 뒤 연결해 주세요.', { reason: 'session' }); + } + if (user.providerData.some((d) => d.providerId === spec.providerId)) { + fail(ERR.authAlreadyLinked, `이미 다른 ${spec.label} 계정이 연결되어 있습니다.`); + } + try { + await getAuth().updateUser(uid, { providerToLink: linkInfo(spec, p) }); + } catch (e) { + const code = (e as { code?: string }).code ?? ''; + if (code.includes('already-in-use') || code.includes('already-linked') || code.includes('already-exists')) { + fail(ERR.authCredentialInUse, inUse); + } + throw e; + } + if ((await ownerOf(spec, p.id)) !== uid) fail(ERR.authCredentialInUse, inUse); +} + +/** + * callable 본문 — `link: true` 면 로그인한 호출자 UID 에 붙이고 `{ linked: true }`, 아니면 로그인할 UID 를 + * 찾거나 만들어 `{ token }`(커스텀 토큰)을 돌려준다. + */ +export async function exchangeSocial( + spec: SocialSpec, + profile: SocialProfile, + req: { data: unknown; auth?: { uid: string; token?: { auth_time?: unknown } } }, +): Promise<{ linked: true } | { token: string }> { + if ((req.data as Record).link === true) { + const uid = req.auth?.uid; + if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.'); + const authTime = req.auth?.token?.auth_time; + await linkSocial(spec, uid, profile, typeof authTime === 'number' ? authTime : undefined); + return { linked: true }; + } + const uid = await resolveSocialUid(spec, profile); + // 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이 + // 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다. + if (profile.email) { + await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true }); + } + return { token: await getAuth().createCustomToken(uid) }; +} diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 4330ebd..92913ce 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -7,4 +7,5 @@ export { fnAuthOnCreate, fnConsentRecord, fnAdminSetRole, } from './account/account.js'; export { fnAuthKakao } from './account/kakao.js'; +export { fnAuthLine } from './account/line.js'; export { fnAdminWrite } from './admin/admin.js'; diff --git a/app/firebase/functions/test/auth_line.test.ts b/app/firebase/functions/test/auth_line.test.ts new file mode 100644 index 0000000..9cbb838 --- /dev/null +++ b/app/firebase/functions/test/auth_line.test.ts @@ -0,0 +1,146 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { getAuth } from 'firebase-admin/auth'; +import { getFirestore } from 'firebase-admin/firestore'; + +import { bootAdmin, shutdownAdmin } from './support.js'; +import { fnAuthLine, LINE_PROVIDER_ID, lineUidOf } from '../src/account/line.js'; + +/** LINE 커스텀 토큰 — ID 토큰 · 액세스 토큰 검증 · 기존 oidc.line 사용자 매핑 · 신규 UID · 이메일 · SNS 연동. */ + +const CHANNEL = '2000000001'; +const LINE_ID = 'U0123456789abcdef0123456789abcdef'; +const OLD_UID = 'test-line-oidc-user'; +const GOOGLE_UID = 'test-line-google-user'; + +type Req = Parameters[0]; +const call = (data: Record, uid?: string) => + fnAuthLine.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; + } +}; +const uidOf = (token: string) => JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid as string; + +/** + * LINE API 흉내. ID 토큰 'good' · 액세스 토큰 'good' 만 우리 채널 것, 'other-app' 은 남의 채널, 'expired' 는 만료. + * 실제 LINE 처럼 ID 토큰 검증은 보낸 client_id 와 aud 가 다르면 400 이다. + */ +function stubLine(email?: string) { + vi.stubGlobal('fetch', vi.fn(async (url: string, init?: { method?: string; body?: string; headers?: Record }) => { + if (init?.method === 'POST' && url.endsWith('/oauth2/v2.1/verify')) { + const form = new URLSearchParams(init.body); + const token = form.get('id_token'); + if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 }); + const aud = token === 'other-app' ? '1' : CHANNEL; + if (form.get('client_id') !== aud) return Response.json({ error: 'invalid_request' }, { status: 400 }); + return Response.json({ iss: 'https://access.line.me', sub: LINE_ID, aud, name: 'LINE팬', ...(email ? { email } : {}) }); + } + if (url.includes('/oauth2/v2.1/verify?access_token=')) { + const token = new URL(url).searchParams.get('access_token'); + if (token === 'expired') return Response.json({ error: 'invalid_request' }, { status: 400 }); + return Response.json({ scope: 'profile openid', client_id: token === 'other-app' ? '1' : CHANNEL, expires_in: 100 }); + } + if (url.endsWith('/v2/profile')) return Response.json({ userId: LINE_ID, displayName: 'LINE팬' }); + return new Response('{}', { status: 404 }); + })); +} + +async function wipe() { + await getAuth().deleteUsers([OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]); + for (const uid of [OLD_UID, GOOGLE_UID, lineUidOf(LINE_ID)]) await getFirestore().doc(`users/${uid}`).delete(); +} + +beforeAll(() => { + if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) { + throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.'); + } + process.env.LINE_CHANNEL_ID = CHANNEL; + bootAdmin(); +}); +afterAll(async () => { await wipe(); await shutdownAdmin(); }); +beforeEach(async () => { await wipe(); stubLine(); }); +afterEach(() => { vi.unstubAllGlobals(); }); + +describe('토큰 검증', () => { + it('만료 · 남의 채널 토큰(ID · 액세스 모두)과 토큰 없음은 E-AUTH-03', async () => { + for (const key of ['idToken', 'accessToken']) { + expect(await codeOf(call({ [key]: 'expired' }))).toBe('E-AUTH-03'); + expect(await codeOf(call({ [key]: 'other-app' }))).toBe('E-AUTH-03'); + } + expect(await codeOf(call({}))).toBe('E-AUTH-03'); + }); + + it('ID 토큰이 있으면 ID 토큰으로 검증한다(액세스 토큰이 남의 채널 것이어도 보지 않는다)', async () => { + const { token } = await call({ idToken: 'good', accessToken: 'other-app' }) as { token: string }; + expect(uidOf(token)).toBe(lineUidOf(LINE_ID)); + }); +}); + +describe('로그인', () => { + it('OIDC 시절 oidc.line 가입자는 같은 UID 로 들어온다', async () => { + await getAuth().createUser({ uid: OLD_UID }); + await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: LINE_ID } }); + const { token } = await call({ idToken: 'good' }) as { token: string }; + expect(uidOf(token)).toBe(OLD_UID); + }); + + it('신규는 line: 로 만들고 oidc.line 을 붙인다 · 액세스 토큰만으로도 같은 UID', async () => { + const first = await call({ idToken: 'good' }) as { token: string }; + const second = await call({ accessToken: 'good' }) as { token: string }; + expect(uidOf(first.token)).toBe(lineUidOf(LINE_ID)); + expect(uidOf(second.token)).toBe(lineUidOf(LINE_ID)); + const user = await getAuth().getUser(lineUidOf(LINE_ID)); + expect(user.displayName).toBe('LINE팬'); + expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[LINE_PROVIDER_ID, LINE_ID]]); + }); + + it('이메일은 LINE 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다', async () => { + await call({ idToken: 'good' }); + expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBeUndefined(); + stubLine('fan@line.me'); + await call({ idToken: 'good' }); + expect((await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).get()).get('email')).toBe('fan@line.me'); + }); +}); + +describe('SNS 연동', () => { + it('로그인 없이 link 는 E-AUTH-03', async () => { + expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03'); + }); + + it('Google 사용자에 붙인다(이메일이 달라도 · 없어도) · 다른 UID 가 쓰는 LINE 은 E-AUTH-08', async () => { + await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' }); + stubLine('other@line.me'); + expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + const user = await getAuth().getUser(GOOGLE_UID); + expect(user.providerData.some((d) => d.providerId === LINE_PROVIDER_ID && d.uid === LINE_ID)).toBe(true); + + await getAuth().createUser({ uid: OLD_UID }); + expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08'); + }); + + it('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', async () => { + await getAuth().createUser({ uid: GOOGLE_UID }); + const before = Math.floor(Date.now() / 1000) - 60; + await getAuth().revokeRefreshTokens(GOOGLE_UID); + const linkAs = (authTime: number) => fnAuthLine.run( + { data: { idToken: 'good', link: true }, auth: { uid: GOOGLE_UID, token: { auth_time: authTime } } } as Req, + ); + expect(await codeOf(linkAs(before))).toBe('session'); + await getAuth().updateUser(GOOGLE_UID, { disabled: true }); + expect(await codeOf(linkAs(Math.floor(Date.now() / 1000) + 5))).toBe('session'); + expect((await getAuth().getUser(GOOGLE_UID)).providerData).toEqual([]); + }); + + it('이미 다른 LINE 이 붙어 있으면 E-AUTH-09 · 같은 LINE 이면 그대로 성공', async () => { + await getAuth().createUser({ uid: GOOGLE_UID }); + await getAuth().updateUser(GOOGLE_UID, { providerToLink: { providerId: LINE_PROVIDER_ID, uid: 'U-other' } }); + expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('E-AUTH-09'); + await getAuth().deleteUser(GOOGLE_UID); + await getAuth().createUser({ uid: GOOGLE_UID }); + expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + expect(await call({ idToken: 'good', link: true }, GOOGLE_UID)).toEqual({ linked: true }); + }); +}); diff --git a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index bc0e8f4..0e968d9 100644 --- a/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/app/ios/Runner.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -126,6 +126,15 @@ "version" : "1.22.5" } }, + { + "identity" : "line-sdk-ios-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/line/line-sdk-ios-swift.git", + "state" : { + "revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352", + "version" : "5.17.0" + } + }, { "identity" : "nanopb", "kind" : "remoteSourceControl", diff --git a/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved b/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved index bc0e8f4..0e968d9 100644 --- a/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/app/ios/Runner.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -126,6 +126,15 @@ "version" : "1.22.5" } }, + { + "identity" : "line-sdk-ios-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/line/line-sdk-ios-swift.git", + "state" : { + "revision" : "230d05aab8ae48b6eeb893b9f257d7f5095d3352", + "version" : "5.17.0" + } + }, { "identity" : "nanopb", "kind" : "remoteSourceControl", diff --git a/app/ios/Runner/Info.plist b/app/ios/Runner/Info.plist index 0bf47dc..bf92f04 100644 --- a/app/ios/Runner/Info.plist +++ b/app/ios/Runner/Info.plist @@ -100,11 +100,24 @@ kakao$(FANUZU_KAKAO_NATIVE_APP_KEY) + + + CFBundleTypeRole + Editor + CFBundleURLName + line-login + CFBundleURLSchemes + + line3rdp.$(PRODUCT_BUNDLE_IDENTIFIER) + + LSApplicationQueriesSchemes kakaokompassauth + + lineauth2 diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 8c2cd2d..d1d88b9 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart'; import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; -import 'firebase_account_linker.dart'; import 'social_providers.dart'; /// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. @@ -45,9 +44,7 @@ class AuthRepository extends ChangeNotifier { /// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다. /// QA 경로는 [qaEmailProvider] 하나뿐이다. /// - /// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC - /// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를 - /// 돌려준다. + /// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다. static List _defaultProviders() => [ const GoogleSignInProvider(), // Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 — @@ -63,18 +60,8 @@ class AuthRepository extends ChangeNotifier { // 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱 // 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다. KakaoSignInProvider(), - // LINE 동의 항목: profile·email(이메일 권한 신청 승인 후). - OidcSignInProvider( - AuthProviderKind.line, - providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!, - scopes: const ['profile', 'email'], - // LINE 앱 자동 로그인으로 넘어가면 콜백이 웹 로그인 창 밖에서 열려 로그인이 - // 깨진다(Android · iOS). LINE Login 공식 파라미터로 끈다. - appParameters: const { - 'disable_auto_login': 'true', - 'disable_ios_auto_login': 'true', - }, - ), + // LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다). + LineSignInProvider(), ]; /// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**. diff --git a/app/lib/features/auth/firebase_account_linker.dart b/app/lib/features/auth/firebase_account_linker.dart index 01f3f1b..3e50e46 100644 --- a/app/lib/features/auth/firebase_account_linker.dart +++ b/app/lib/features/auth/firebase_account_linker.dart @@ -27,7 +27,7 @@ final class FirebaseAccountLinker implements AccountLinker { AuthProviderKind.apple: 'apple.com', // 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다. // 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을 - // 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다. + // 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao · fnAuthLine)가 같은 ID 로 연결을 남긴다. AuthProviderKind.kakao: 'oidc.kakao', AuthProviderKind.line: 'oidc.line', AuthProviderKind.email: 'password', @@ -58,12 +58,14 @@ final class FirebaseAccountLinker implements AccountLinker { if (missing.isNotEmpty) return AuthNotConfigured(provider, missing); if (impl is! CredentialProvider && impl is! OidcSignInProvider && - impl is! KakaoSignInProvider) { + impl is! KakaoSignInProvider && + impl is! LineSignInProvider) { return AuthNotConfigured(provider, const ['자격증명 구현']); } try { if (impl is KakaoSignInProvider) return await impl.link(user); + if (impl is LineSignInProvider) return await impl.link(user); if (impl is OidcSignInProvider) return await _linkOidc(user, impl); final source = impl as CredentialProvider; final obtained = await source.obtainCredential(); @@ -114,7 +116,7 @@ final class FirebaseAccountLinker implements AccountLinker { } } - /// LINE(OIDC) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다. + /// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다. Future _linkOidc(fb.User user, OidcSignInProvider impl) async { final result = await retryStaleWebContext( () => untilAbandoned(user.linkWithProvider(impl.authProvider)), diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index 1632fcb..e8dd684 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -8,6 +8,7 @@ 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:flutter_line_sdk/flutter_line_sdk.dart' as line; 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; @@ -203,8 +204,9 @@ final class AppleSignInProvider kIsWeb || defaultTargetPlatform == TargetPlatform.android; } -/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나 -/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다). +/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이 +/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀 +/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다). /// /// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을 /// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이 @@ -224,7 +226,6 @@ final class OidcSignInProvider implements SocialAuthProvider { this.provider, { required this.providerId, this.scopes = const [], - this.appParameters = const {}, this.firebaseAuth, }); @@ -239,12 +240,6 @@ final class OidcSignInProvider implements SocialAuthProvider { /// KOE205 로 로그인 자체를 막는다. final List scopes; - /// 앱(Android · iOS)에서 인가 URL 에 붙일 파라미터. 웹 로그인 도중 제공자 앱(LINE 등)으로 - /// 넘어갔다 돌아오면 콜백이 원래 브라우저 세션 밖에서 열려 Firebase 핸들러가 - /// 「missing initial state」로 끝난다(Android 10/5 · iOS 10/11 실기기) — 앱 전환을 막는 - /// 제공자 공식 파라미터를 여기 둔다. - final Map appParameters; - /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. final fb.FirebaseAuth? firebaseAuth; @@ -260,7 +255,6 @@ final class OidcSignInProvider implements SocialAuthProvider { for (final scope in scopes) { p.addScope(scope); } - if (!kIsWeb) p.setCustomParameters(appParameters); return p; } @@ -405,17 +399,7 @@ final class KakaoSignInProvider implements SocialAuthProvider { debugPrint('[KakaoSignInProvider] sdk error: $error'); return const AuthFailed('E-AUTH-03', '소셜 로그인에 실패했습니다.'); } 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'] == 'session' => - '보안을 위해 다시 로그인한 뒤 시도해 주세요.', - 'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', - 'E-AUTH-09' => '이미 연결된 계정입니다.', - _ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', - }); + return _serverFailure(error, flow); } on fb.FirebaseAuthException catch (error) { return AuthFailed( authCatalogCode(error, flow: flow), @@ -437,6 +421,158 @@ final class KakaoSignInProvider implements SocialAuthProvider { } } +/// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다. +AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) { + 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'] == 'session' => + '보안을 위해 다시 로그인한 뒤 시도해 주세요.', + 'E-AUTH-08' => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', + 'E-AUTH-09' => '이미 연결된 계정입니다.', + _ => flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', + }); +} + +/// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다. +const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID'); + +/// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다. +typedef LineTokens = ({String? idToken, String accessToken}); + +/// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다. +/// +/// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다 +/// (`disable_ios_auto_login` 을 붙여도 같다). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로 로그인해 받은 +/// 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다. +/// +/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고, +/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다. +/// +/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에 +/// 오고, 서버가 그때만 남긴다. 없어도 로그인 · 연결이 된다. +final class LineSignInProvider implements SocialAuthProvider { + LineSignInProvider({ + Future Function()? login, + Future> Function(Map payload)? + exchange, + this.firebaseAuth, + this.channelId = lineChannelId, + }) : _login = login ?? _sdkLogin, + _exchange = exchange ?? _callServer; + + final Future Function() _login; + final Future> Function(Map) _exchange; + + /// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance]. + final fb.FirebaseAuth? firebaseAuth; + final String channelId; + + static Future? _setup; + + /// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled). + static const _cancelCodes = {'CANCEL', '3003'}; + + @override + SocialProvider get provider => SocialProvider.line; + + @override + bool get isLive => true; + + @override + List get missingConfig => [ + if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels, + if (channelId.isEmpty) 'LINE 채널 ID', + ]; + + fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance; + + static Future _sdkLogin() async { + await (_setup ??= line.LineSDK.instance.setup(lineChannelId)); + final result = await line.LineSDK.instance.login( + scopes: const ['profile', 'openid', 'email'], + ); + return ( + idToken: result.accessToken.idTokenRaw, + accessToken: result.accessToken.value, + ); + } + + static Future> _callServer( + Map payload, + ) async { + final result = await FirebaseFunctions.instanceFor( + region: Backend.region, + ).httpsCallable('fnAuthLine').call(payload); + final data = result.data; + return data is Map ? Map.from(data) : const {}; + } + + Future> _tokens() async { + final t = await _login(); + return { + if (t.idToken != null) 'idToken': t.idToken, + 'accessToken': t.accessToken, + }; + } + + @override + Future signIn() => _run(AuthFlow.signIn, () async { + final data = await _exchange(await _tokens()); + final result = await _auth.signInWithCustomToken(data['token'] as String); + return AuthSucceeded(_toAccount(result.user, provider)); + }); + + /// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는 + /// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다. + Future link(fb.User user) => _run(AuthFlow.link, () async { + await _exchange({...await _tokens(), '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 (_cancelCodes.contains(error.code)) return const AuthCancelled(); + debugPrint( + '[LineSignInProvider] sdk failed: ${error.code} ${error.message}', + ); + return AuthFailed( + 'E-AUTH-03', + flow == AuthFlow.link ? '계정 연결에 실패했습니다.' : '소셜 로그인에 실패했습니다.', + ); + } on FirebaseFunctionsException catch (error) { + return _serverFailure(error, flow); + } on fb.FirebaseAuthException catch (error) { + return AuthFailed( + authCatalogCode(error, flow: flow), + authMessage(error, flow: flow), + ); + } + } + + @override + Future signOut() async { + // SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다. + if (_setup == null) return; + try { + await line.LineSDK.instance.logout(); + } on Object catch (error) { + // 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다. + debugPrint('[LineSignInProvider] logout: ${error.runtimeType}'); + } + } +} + /// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다. /// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름). bool isWebFlowCancelled(fb.FirebaseAuthException error) => @@ -676,8 +812,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) => 'invalid-email' => '이메일 형식이 올바르지 않습니다.', 'too-many-requests' => '잠시 후 다시 시도해 주세요.', 'account-exists-with-different-credential' => switch (flow) { - AuthFlow.signIn || AuthFlow.email => - _otherProviderSignInGuide(error.email), + AuthFlow.signIn || + AuthFlow.email => _otherProviderSignInGuide(error.email), AuthFlow.link => '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.', }, // 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다. diff --git a/app/pubspec.lock b/app/pubspec.lock index 0dad837..83f93ac 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -347,6 +347,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.14.4" + flutter_line_sdk: + dependency: "direct main" + description: + name: flutter_line_sdk + sha256: a311289a2f38d1c1c5ea05fc6b421185e571752f51b84df8e01c195e8e890d02 + url: "https://pub.dev" + source: hosted + version: "2.7.2" flutter_lints: dependency: "direct dev" description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 97a0ed2..26a91c6 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -37,6 +37,11 @@ dependencies: # Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 SDK 를 쓴다. # 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0). kakao_flutter_sdk_user: ^2.0.1 + # LINE 로그인 — LINE 공식 SDK. LINE 앱이 있으면 앱으로, 없으면 SDK 웹 화면으로 로그인해 ID · 액세스 토큰을 + # 받고 서버(fnAuthLine)가 Firebase 커스텀 토큰으로 바꿔 준다. iOS 에서 Firebase OIDC 웹 흐름이 첫 시도에 + # 「missing initial state」로 깨져서 SDK 를 쓴다. 3.x 는 AGP 9 · Dart 3.12 전제라 2.x 를 쓴다. + # 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0). + flutter_line_sdk: ^2.7.2 dev_dependencies: flutter_test: diff --git a/app/test/auth_line_test.dart b/app/test/auth_line_test.dart new file mode 100644 index 0000000..c58161b --- /dev/null +++ b/app/test/auth_line_test.dart @@ -0,0 +1,171 @@ +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'; + +/// LINE — SDK 로그인(주입) → 서버 교환(주입) → signInWithCustomToken 계약. +/// 토큰 검증·기존 oidc.line 사용자 매핑은 서버 테스트(functions/test/auth_line.test.ts)가 본다. +void main() { + setUp(() => FirebaseBoot.debugSetMode(FirebaseMode.live, reason: 'LINE 테스트')); + tearDown( + () => FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: 'LINE 테스트 끝'), + ); + + final sent = >[]; + setUp(sent.clear); + + LineSignInProvider line( + MockFirebaseAuth auth, { + Future Function()? login, + Object? serverError, + String channelId = '2000000001', + }) => LineSignInProvider( + login: + login ?? () async => (idToken: 'line-id', accessToken: 'line-access'), + exchange: (payload) async { + sent.add(payload); + if (serverError != null) throw serverError; + return { + 'token': 'custom-token', + if (payload['link'] == true) 'linked': true, + }; + }, + firebaseAuth: auth, + channelId: channelId, + ); + + test('성공 — LINE 토큰을 서버에 넘기고 커스텀 토큰으로 로그인한다', () async { + final auth = MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1')); + final outcome = await line(auth).signIn(); + expect(sent.single, {'idToken': 'line-id', 'accessToken': 'line-access'}); + expect((outcome as AuthSucceeded).account.uid, 'line:U1'); + expect(outcome.account.provider, AuthProviderKind.line); + expect(auth.currentUser?.uid, 'line:U1'); + }); + + test('ID 토큰이 없으면(openid 미동의) 액세스 토큰만 넘긴다', () async { + await line( + MockFirebaseAuth(), + login: () async => (idToken: null, accessToken: 'line-access'), + ).signIn(); + expect(sent.single, {'accessToken': 'line-access'}); + }); + + test('채널 ID 가 없으면 SDK·서버를 부르지 않고 미설정', () async { + final outcome = await line(MockFirebaseAuth(), channelId: '').signIn(); + expect(outcome, isA()); + expect(sent, isEmpty); + }); + + test( + 'LINE 로그인 취소(Android CANCEL · iOS 3003)는 취소 · 서버를 부르지 않는다 · 다시 시도하면 된다', + () async { + for (final code in ['CANCEL', '3003']) { + final outcome = await line( + MockFirebaseAuth(), + login: () async => throw PlatformException(code: code), + ).signIn(); + expect(outcome, isA(), reason: code); + } + expect(sent, isEmpty); + final retry = await line( + MockFirebaseAuth(mockUser: MockUser(uid: 'line:U1')), + ).signIn(); + expect(retry, isA()); + }, + ); + + test('SDK 실패는 소셜 문구 E-AUTH-03', () async { + final outcome = await line( + MockFirebaseAuth(), + login: () async => + throw PlatformException(code: 'AUTHENTICATION_AGENT_ERROR'), + ).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(outcome.message, '소셜 로그인에 실패했습니다.'); + expect(sent, isEmpty); + }); + + test('서버 오류는 카탈로그 코드로 · 로그인하지 않는다', () async { + final auth = MockFirebaseAuth(); + final outcome = await line( + auth, + serverError: FirebaseFunctionsException( + code: 'permission-denied', + message: 'LINE 인증을 확인하지 못했습니다.', + details: {'code': 'E-AUTH-03'}, + ), + ).signIn(); + expect((outcome as AuthFailed).code, 'E-AUTH-03'); + expect(outcome.message, '소셜 로그인에 실패했습니다.'); + expect(auth.currentUser, isNull); + }); + + group('SNS 연동', () { + test('로그인한 사용자에 link: true 로 붙인다', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + final linker = FirebaseAccountLinker( + providers: [line(auth)], + firebaseAuth: auth, + ); + final outcome = await linker.link(AuthProviderKind.line); + expect(sent.single, { + 'idToken': 'line-id', + 'accessToken': 'line-access', + 'link': true, + }); + expect((outcome as AuthSucceeded).account.uid, 'u-1'); + }); + + test('연결 중 취소는 취소 · 서버를 부르지 않는다', () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + final outcome = await line( + auth, + login: () async => throw PlatformException(code: 'CANCEL'), + ).link(auth.currentUser!); + expect(outcome, isA()); + expect(sent, isEmpty); + }); + + test( + '다른 계정에 붙은 LINE 이면 E-AUTH-08 · 이미 다른 LINE 이면 E-AUTH-09 · 세션 만료면 다시 로그인 안내', + () async { + final auth = MockFirebaseAuth( + signedIn: true, + mockUser: MockUser(uid: 'u-1'), + ); + Future failWith(Map details) => line( + auth, + serverError: FirebaseFunctionsException( + code: 'x', + message: 'x', + details: details, + ), + ).link(auth.currentUser!); + + final inUse = await failWith({'code': 'E-AUTH-08'}); + expect((inUse as AuthFailed).code, 'E-AUTH-08'); + expect(inUse.message, '다른 FANUZU 계정에 이미 연결된 소셜 계정입니다.'); + expect( + ((await failWith({'code': 'E-AUTH-09'})) as AuthFailed).code, + 'E-AUTH-09', + ); + final expired = await failWith({ + 'code': 'E-AUTH-03', + 'reason': 'session', + }); + expect((expired as AuthFailed).message, '보안을 위해 다시 로그인한 뒤 시도해 주세요.'); + }, + ); + }); +} diff --git a/app/test/auth_oidc_test.dart b/app/test/auth_oidc_test.dart index bd3aa67..3ff5a99 100644 --- a/app/test/auth_oidc_test.dart +++ b/app/test/auth_oidc_test.dart @@ -12,7 +12,7 @@ import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; -/// LINE OIDC 제공자 — 실 로그인은 못 하고, +/// Firebase 웹 흐름(OIDC) 제공자 — 지금은 Android Apple 이 쓴다(LINE 을 예시 제공자로 둔다). 실 로그인은 못 하고, /// Firebase 호출 전후 계약(결과 매핑·취소·연결)만 확인한다. void main() { // Android 에서 signIn 이 앱 생명주기를 듣는다(untilAbandoned). @@ -28,18 +28,11 @@ void main() { firebaseAuth: auth, ); - test('기본 목록: LINE 은 Firebase OIDC, 카카오는 SDK + 서버 커스텀 토큰', () { + test('기본 목록: 카카오 · LINE 은 SDK + 서버 커스텀 토큰', () { final providers = { for (final p in AuthRepository().providers) p.provider: p, }; - 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.line], isA()); expect(providers[AuthProviderKind.kakao], isA()); }); @@ -87,24 +80,6 @@ void main() { expect((outcome as AuthFailed).code, 'E-AUTH-11'); }); - test('LINE 은 Android · iOS 모두 앱 자동 로그인을 끈다', () { - final line = - AuthRepository().providers.firstWhere( - (p) => p.provider == AuthProviderKind.line, - ) - as OidcSignInProvider; - expect(line.authProvider.parameters, { - 'disable_auto_login': 'true', - 'disable_ios_auto_login': 'true', - }); - debugDefaultTargetPlatformOverride = TargetPlatform.iOS; - addTearDown(() => debugDefaultTargetPlatformOverride = null); - expect(line.authProvider.parameters, { - 'disable_auto_login': 'true', - 'disable_ios_auto_login': 'true', - }); - }); - testWidgets('Android: 앱으로 돌아왔는데 결과가 없으면 취소로 풀린다', (tester) async { late AuthOutcome outcome; line(_HangingAuth()).signIn().then((o) => outcome = o); From c40bde2cd08023e8626144c4788eb14449cca45e Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 11 Oct 2026 23:43:25 +0900 Subject: [PATCH 11/19] =?UTF-8?q?=EA=B0=80=EC=9E=85=20=EC=8B=9C=20?= =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98=20=EB=8B=A8=EA=B3=84=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=E2=80=94=20=ED=95=84=EC=88=98=C2=B7?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=ED=95=AD=EB=AA=A9=20=EA=B5=AC=EB=B6=84=20?= =?UTF-8?q?=C2=B7=20=EA=B0=9C=EC=9D=B8=EC=A0=95=EB=B3=B4=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=B0=A9=EC=B9=A8=20=EA=B3=A0=EC=A7=80=20=C2=B7=20=EB=8F=99?= =?UTF-8?q?=EC=9D=98=20=EA=B8=B0=EB=A1=9D=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/firebase/functions/src/account/account.ts | 53 +++- app/firebase/functions/src/index.ts | 2 +- .../functions/test/signup_consent.test.ts | 51 ++++ app/lib/core/router/app_router.dart | 22 +- app/lib/features/auth/auth_repository.dart | 73 +++++- app/lib/features/auth/login_screen.dart | 14 +- app/lib/features/auth/signup_screens.dart | 227 +++++++++++++++--- app/lib/main.dart | 27 ++- app/test/signup_terms_test.dart | 146 +++++++++++ 9 files changed, 564 insertions(+), 51 deletions(-) create mode 100644 app/firebase/functions/test/signup_consent.test.ts create mode 100644 app/test/signup_terms_test.dart diff --git a/app/firebase/functions/src/account/account.ts b/app/firebase/functions/src/account/account.ts index 953c049..d3200df 100644 --- a/app/firebase/functions/src/account/account.ts +++ b/app/firebase/functions/src/account/account.ts @@ -1,5 +1,5 @@ import * as functionsV1 from 'firebase-functions/v1'; -import { onCall } from 'firebase-functions/v2/https'; +import { HttpsError, onCall } from 'firebase-functions/v2/https'; import { getFirestore, FieldValue } from 'firebase-admin/firestore'; import { getAuth } from 'firebase-admin/auth'; @@ -85,6 +85,57 @@ export const fnConsentRecord = onCall(async (req) => { return { consentId }; }); +/** + * 가입 약관 필수 항목 — 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인. + * + * 위치기반 서비스 약관은 가입 때 받지 않는다(위치기능을 처음 쓰기 직전에 따로 받는다). + * 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF. + */ +export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const; + +/** + * `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다. + * + * 항목마다 `consents` 에 append-only 행을 남기고(약관 버전 포함), 사용자 문서에 `termsAgreedAt` 을 + * 찍는다. 앱은 이 값이 없으면 로그인 직후 약관 화면으로 보낸다 — 가입 · 로그인 버튼이나 제공자와 + * 무관하게 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다. + * 마케팅 동의는 알림 설정의 마케팅 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다. + */ +export const fnSignupConsent = onCall(async (req) => { + const uid = callerUid(req); + const version = str(req.data, 'version'); + const raw = (req.data as Record)?.agreed; + const agreed = new Set(Array.isArray(raw) ? raw.filter((v): v is string => typeof v === 'string') : []); + if (!SIGNUP_REQUIRED_TERMS.every((t) => agreed.has(t))) { + throw new HttpsError('invalid-argument', '필수 약관에 모두 동의해 주세요.'); + } + const marketing = agreed.has('marketing'); + const db = getFirestore(); + const batch = db.batch(); + const meta = { + docVersion: version, + ip: req.rawRequest?.ip ?? null, + userAgent: req.rawRequest?.headers?.['user-agent'] ?? null, + createdAt: FieldValue.serverTimestamp(), + }; + for (const docType of [...SIGNUP_REQUIRED_TERMS, 'marketing']) { + const granted = docType === 'marketing' ? marketing : true; + batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`), + { docType, granted, ...meta }, { merge: true }); + } + batch.set(db.doc(`users/${uid}`), { + termsAgreedAt: FieldValue.serverTimestamp(), + termsVersion: version, + notificationPrefs: { marketing }, + consentState: { + marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() }, + }, + updatedAt: FieldValue.serverTimestamp(), + }, { merge: true }); + await batch.commit(); + return { termsVersion: version, marketing }; +}); + /** * `fn-admin-set-role` — 마스터 전용. 운영자 등급을 커스텀 클레임으로 심는다. * diff --git a/app/firebase/functions/src/index.ts b/app/firebase/functions/src/index.ts index 92913ce..288845b 100644 --- a/app/firebase/functions/src/index.ts +++ b/app/firebase/functions/src/index.ts @@ -4,7 +4,7 @@ import './core/bootstrap.js'; export { - fnAuthOnCreate, fnConsentRecord, fnAdminSetRole, + fnAuthOnCreate, fnConsentRecord, fnSignupConsent, fnAdminSetRole, } from './account/account.js'; export { fnAuthKakao } from './account/kakao.js'; export { fnAuthLine } from './account/line.js'; diff --git a/app/firebase/functions/test/signup_consent.test.ts b/app/firebase/functions/test/signup_consent.test.ts new file mode 100644 index 0000000..f0c6b28 --- /dev/null +++ b/app/firebase/functions/test/signup_consent.test.ts @@ -0,0 +1,51 @@ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import { getFirestore } from 'firebase-admin/firestore'; + +import { bootAdmin, shutdownAdmin } from './support.js'; +import { fnSignupConsent } from '../src/account/account.js'; + +/** 가입 약관 동의 — 필수 3항 검사 · 항목별 동의 행 · 동의 시각 기록. */ + +const U = 'test-consent-user'; +const db = () => getFirestore(); + +type CallReq = Parameters[0]; +const as = (uid: string, data: Record) => ({ data, auth: { uid, token: {} } }) as CallReq; +const codeOf = async (p: Promise) => { + try { await p; return 'ok'; } catch (e) { return (e as { code?: string }).code; } +}; + +const wipe = () => db().recursiveDelete(db().doc(`users/${U}`)); + +beforeAll(() => { bootAdmin(); }); +afterAll(async () => { await wipe(); await shutdownAdmin(); }); +beforeEach(wipe); + +describe('fnSignupConsent', () => { + it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부하고 기록하지 않는다', async () => { + expect(await codeOf(fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] })))) + .toBe('invalid-argument'); + expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined(); + }); + + it('필수만 동의 — 항목별 행 · 마케팅 기본 OFF · termsAgreedAt', async () => { + await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age'] })); + const user = (await db().doc(`users/${U}`).get()).data()!; + expect(user.termsAgreedAt).toBeDefined(); + expect(user.termsVersion).toBe('v1'); + expect(user.notificationPrefs.marketing).toBe(false); + const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id).sort(); + expect(rows).toEqual(['age_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']); + }); + + it('마케팅 선택 동의는 알림 마케팅 구분과 같은 값이 된다', async () => { + await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'age', 'marketing'] })); + expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true); + expect((await db().doc(`users/${U}/consents/marketing_v1_grant`).get()).get('granted')).toBe(true); + }); + + it('로그인하지 않은 호출은 거부', async () => { + expect(await codeOf(fnSignupConsent.run({ data: { version: 'v1', agreed: [] } } as CallReq))) + .not.toBe('ok'); + }); +}); diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index 5ca939e..ca7904f 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -6,6 +6,7 @@ import '../theme/tokens.dart'; import '../ui/app_icons.dart'; import '../../features/auth/auth_repository.dart'; import '../../features/auth/login_screen.dart'; +import '../../features/auth/signup_screens.dart'; import '../../features/galaxy/galaxy_screen.dart'; import '../../features/home/home_screen.dart'; import '../../features/inside/inside_screen.dart'; @@ -77,19 +78,24 @@ List visibleTabs(FeatureFlags flags) => [ const String loginPath = '/login'; -/// 5탭 셸 라우트 + 온보딩(로그인) 라우트. +/// 가입 약관 — 동의 기록(`termsAgreedAt`)이 없는 계정은 셸 대신 여기로 온다. +const String termsPath = '/terms'; + +/// 5탭 셸 라우트 + 로그인 · 가입 약관 라우트. /// -/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST · -/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다. +/// 로그인 → (동의 기록이 없으면) 약관([termsPath]) → 셸. 가입 · 로그인 버튼이나 제공자와 +/// 무관하게 약관 동의 없이 셸에 들어가지 못한다. GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { final featureFlags = flags ?? FeatureFlags(); return GoRouter( initialLocation: appTabs.first.path, refreshListenable: auth, redirect: (context, state) { - final atLogin = state.matchedLocation == loginPath; - if (!auth.isSignedIn) return atLogin ? null : loginPath; - if (atLogin) return appTabs.first.path; + final at = state.matchedLocation; + if (!auth.isSignedIn) return at == loginPath ? null : loginPath; + if (auth.terms != TermsGate.done) + return at == termsPath ? null : termsPath; + if (at == loginPath || at == termsPath) return appTabs.first.path; return null; }, routes: [ @@ -97,6 +103,10 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) { path: loginPath, builder: (_, _) => LoginScreen(auth: auth), ), + GoRoute( + path: termsPath, + builder: (_, _) => SignupTermsGate(auth: auth), + ), StatefulShellRoute.indexedStack( builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags), branches: [ diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index d1d88b9..5de798e 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -6,21 +6,85 @@ import '../../core/firebase/firebase_boot.dart'; import 'auth.dart'; import 'social_providers.dart'; -/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다. +/// 가입 약관 관문 — 로그인한 계정이 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`). +enum TermsGate { checking, required, done } + +/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/약관/셸을 가른다. class AuthRepository extends ChangeNotifier { AuthRepository({ List? providers, AuthSession? session, EmailAuthProvider? qaEmailProvider, bool? releaseMode, + Future Function(String uid)? needsTerms, + Future Function(Set agreed)? recordTerms, }) : providers = providers ?? _defaultProviders(), _qaEmail = qaEmailProvider ?? const EmailPasswordProvider(), _session = session, + _needsTerms = needsTerms, + _recordTerms = recordTerms, _account = session?.currentAccount, // 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다 // (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다). _releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) { _sessionSubscription = session?.accountChanges.listen(_replaceAccount); + final initial = _account; + if (initial != null) unawaited(_checkTerms(initial)); + } + + /// 이 계정이 약관 동의를 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트). + /// + /// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼이나 + /// 제공자(카카오 · LINE 커스텀 토큰 포함)와 무관하게, 서버에 동의 기록이 없으면 셸에 들어가지 못한다. + final Future Function(String uid)? _needsTerms; + + /// 동의 기록(`fnSignupConsent`). 필수 누락은 서버가 거부한다. + final Future Function(Set agreed)? _recordTerms; + + TermsGate _gate = TermsGate.done; + TermsGate get terms => _gate; + + /// 가입 모드에서 제공자 인증 **전에** 동의한 약관. 인증 뒤 동의가 필요한 계정이면 + /// 이 값으로 기록하고 약관 화면을 다시 띄우지 않는다. + Set? pendingConsent; + + /// 약관 동의를 서버에 기록하고 셸로 보낸다. 실패하면 예외를 그대로 던진다(화면이 안내). + Future agreeTerms(Set agreed) async { + await _recordTerms?.call(agreed); + _gate = TermsGate.done; + pendingConsent = null; + notifyListeners(); + } + + Future _checkTerms(AuthAccount account) async { + final check = _needsTerms; + if (check == null || account.isMock) { + _gate = TermsGate.done; + return; + } + _gate = TermsGate.checking; + bool required; + try { + required = await check(account.uid); + } catch (e) { + // 판정하지 못하면 약관부터 다시 받는다 — 동의 없는 계정이 셸로 새는 쪽이 더 나쁘다. + debugPrint('[AuthRepository] terms check failed: ${e.runtimeType}'); + required = true; + } + if (_account?.uid != account.uid) return; + final pending = pendingConsent; + pendingConsent = null; + if (required && pending != null) { + try { + await agreeTerms(pending); + return; + } catch (e) { + debugPrint('[AuthRepository] pending consent failed: ${e.runtimeType}'); + } + if (_account?.uid != account.uid) return; + } + _gate = required ? TermsGate.required : TermsGate.done; + notifyListeners(); } final bool _releaseMode; @@ -172,7 +236,14 @@ class AuthRepository extends ChangeNotifier { void _replaceAccount(AuthAccount? next) { if (_sameAccount(_account, next)) return; + final uidChanged = _account?.uid != next?.uid; _account = next; + if (next == null) { + _gate = TermsGate.done; + pendingConsent = null; + } else if (uidChanged) { + unawaited(_checkTerms(next)); + } notifyListeners(); } diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 18b530f..548af81 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -210,13 +210,16 @@ class _SimpleLoginScreenState extends State { Future _signIn(SocialProvider provider) async { if (widget.signUp) { - final agreed = await Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => const TermsScreen())); - if (agreed != true || !mounted) return; + final agreed = await Navigator.of(context).push>( + MaterialPageRoute(builder: (_) => const TermsScreen()), + ); + if (agreed == null || !mounted) return; + // 인증 뒤 동의가 필요한 계정이면 이 동의를 서버에 기록한다(약관을 다시 묻지 않는다). + widget.auth.pendingConsent = agreed; } setState(() => _busy = true); final outcome = await widget.auth.signIn(provider); + if (outcome is! AuthSucceeded) widget.auth.pendingConsent = null; if (!mounted) return; setState(() => _busy = false); // 키 대기 제공자는 안내 다이얼로그로 끊는다 — 눌렀는데 아무 일도 안 일어나면 @@ -266,7 +269,7 @@ class _SimpleLoginScreenState extends State { for (final kind in _figmaOrder) ...widget.auth.providers.where((p) => p.provider == kind), ]; - return Scaffold( + final scaffold = Scaffold( appBar: AppBar( automaticallyImplyLeading: false, actions: [ @@ -345,6 +348,7 @@ class _SimpleLoginScreenState extends State { ), ), ); + return scaffold; } } diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index 07120fe..a440364 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -1,23 +1,45 @@ import 'package:flutter/material.dart'; +import '../../core/data/backend.dart'; import '../../core/theme/tokens.dart'; +import 'auth_repository.dart'; /// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의). /// -/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를 -/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다. +/// 필수 항목을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를 +/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 로그인 직후 약관 단계는 [onAgreed] 로 받는다. +/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다. /// -/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다. +/// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상. +/// - 선택: 마케팅 수신(기본 OFF). +/// - 위치기반 서비스 약관은 여기서 받지 않는다 — 위치기능 최초 사용 직전에 따로 받는다. +/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 목록 끝에 안내와 보기 링크만 둔다. +/// +/// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 +/// [termsPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). class TermsScreen extends StatefulWidget { - const TermsScreen({super.key}); + const TermsScreen({super.key, this.onAgreed, this.onBack, this.busy = false}); + + /// 로그인 직후 약관 단계에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다. + final ValueChanged>? onAgreed; + final VoidCallback? onBack; + + /// 동의를 기록하는 중 — 버튼을 막고 진행 표시를 띄운다. + final bool busy; @override State createState() => _TermsScreenState(); } +/// 동의 기록에 남는 약관 버전. 본문이 자리 표시 문구인 동안은 `draft` 다. +const signupTermsVersion = 'draft-1'; + +/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키. +List get signupTermKeys => [for (final t in _Term.values) t.name]; + enum _Term { service('서비스 이용약관 (필수)', required: true, body: true), - location('위치기반 서비스 이용약관 (필수)', required: true, body: true), + privacy('개인정보 수집·이용 동의 (필수)', required: true, body: true), age('만 14세 이상입니다. (필수)', required: true, body: false), marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true); @@ -28,7 +50,8 @@ enum _Term { final bool body; } -const _body = +/// 약관 본문 자리 표시 문구. +const termsPlaceholderBody = 'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n' '추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.'; @@ -41,13 +64,88 @@ class _TermsScreenState extends State { bool get _ready => _Term.values.where((t) => t.required).every(_agreed.contains); + void _done() { + final agreed = {for (final t in _agreed) t.name}; + final cb = widget.onAgreed; + cb != null ? cb(agreed) : Navigator.of(context).pop(agreed); + } + + void _back() => (widget.onBack ?? () => Navigator.of(context).pop())(); + void _toggle(_Term t) => setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t)); + void _showPrivacyPolicy() => showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('개인정보처리방침'), + content: const SingleChildScrollView(child: Text(termsPlaceholderBody)), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('확인'), + ), + ], + ), + ); + + Widget _buttons() { + final shape = RoundedRectangleBorder( + borderRadius: BorderRadius.circular(AppRadius.sm), + ); + final busy = widget.busy; + return Row( + children: [ + Expanded( + child: FilledButton( + onPressed: busy ? null : _back, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: shape, + side: const BorderSide(color: _stroke), + backgroundColor: Colors.black, + foregroundColor: AppColors.onCosmic, + ), + child: const Text('이전'), + ), + ), + const SizedBox(width: 10), + Expanded( + child: FilledButton( + onPressed: _ready && !busy ? _done : null, + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(56), + shape: shape, + side: const BorderSide(color: _stroke), + backgroundColor: AppColors.cosmicAccent, + foregroundColor: AppColors.onCosmicAccent, + disabledBackgroundColor: const Color(0xFF333333), + disabledForegroundColor: const Color(0xFF777777), + ), + child: const Text('완료'), + ), + ), + ], + ); + } + @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('이용 약관')), + final scaffold = Scaffold( + // Figma `179:24230` 머리줄 좌측 「<」 — 「이전」과 같은 동작. + appBar: AppBar( + automaticallyImplyLeading: false, + leading: BackButton(onPressed: widget.busy ? null : _back), + title: const Text('이용 약관'), + centerTitle: true, + ), + // 「이전 · 완료」는 화면 하단 고정(56 높이 · 간격 10). 시스템 내비게이션 바 위로 올린다. + bottomNavigationBar: SafeArea( + child: Padding( + padding: const EdgeInsets.all(AppSpace.s4), + child: _buttons(), + ), + ), body: ListView( padding: const EdgeInsets.all(AppSpace.s4), children: [ @@ -85,7 +183,7 @@ class _TermsScreenState extends State { height: 80, child: ClipRect( child: Text( - _body, + termsPlaceholderBody, overflow: TextOverflow.clip, style: TextStyle( fontFamily: AppFonts.display, @@ -108,42 +206,101 @@ class _TermsScreenState extends State { ), ), ], - const SizedBox(height: AppSpace.s6), - Row( - children: [ - Expanded( - child: FilledButton( - onPressed: () => Navigator.of(context).pop(false), - style: FilledButton.styleFrom( - backgroundColor: Colors.black, - foregroundColor: AppColors.onCosmic, + // 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다. + Padding( + padding: const EdgeInsets.symmetric(vertical: AppSpace.s4), + child: Row( + children: [ + const Expanded( + child: Text( + '개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.', + style: TextStyle( + fontFamily: AppFonts.display, + fontFamilyFallback: [AppFonts.body], + fontSize: 12, + height: 1.36, + color: AppColors.onCosmicMuted, + ), ), - child: const Text('이전'), ), - ), - const SizedBox(width: 10), - Expanded( - child: FilledButton( - onPressed: _ready - ? () => Navigator.of(context).pop(true) - : null, - style: FilledButton.styleFrom( - backgroundColor: AppColors.cosmicAccent, - foregroundColor: AppColors.onCosmicAccent, - disabledBackgroundColor: const Color(0xFF333333), - disabledForegroundColor: const Color(0xFF777777), - ), - child: const Text('완료'), + TextButton( + key: const ValueKey('privacy-policy-link'), + onPressed: _showPrivacyPolicy, + child: const Text('보기'), ), - ), - ], + ], + ), ), ], ), ); + if (!widget.busy) return scaffold; + return Stack(children: [scaffold, const BlockingProgress()]); } } +/// 진행 중 화면 전체를 막는 반투명 막 + 가운데 진행 표시. +class BlockingProgress extends StatelessWidget { + const BlockingProgress({super.key}); + + @override + Widget build(BuildContext context) => const Stack( + children: [ + ModalBarrier(dismissible: false, color: Color(0x8A000000)), + Center(child: CircularProgressIndicator()), + ], + ); +} + +/// 로그인 직후 약관 단계 — 동의 기록이 없는 계정은 HOME 대신 여기로 온다. +/// +/// 판정 중(또는 이미 동의한 계정이 셸로 넘어가는 동안)에는 진행 표시만 그린다. +/// 「이전」은 로그아웃하고 로그인 화면으로 돌아간다 — 동의 없이 셸에 들어가는 길이 없다. +class SignupTermsGate extends StatefulWidget { + const SignupTermsGate({super.key, required this.auth}); + + final AuthRepository auth; + + @override + State createState() => _SignupTermsGateState(); +} + +class _SignupTermsGateState extends State { + bool _busy = false; + + Future _agree(Set agreed) async { + setState(() => _busy = true); + try { + await widget.auth.agreeTerms(agreed); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.maybeOf(context) + ?..hideCurrentSnackBar() + ..showSnackBar( + SnackBar( + content: Text( + e is BackendException ? e.message : '요청을 처리하지 못했습니다. 다시 시도해 주세요.', + ), + ), + ); + } finally { + if (mounted) setState(() => _busy = false); + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: widget.auth, + builder: (context, _) => widget.auth.terms == TermsGate.required + ? TermsScreen( + busy: _busy, + onAgreed: _agree, + onBack: widget.auth.signOut, + ) + : const Scaffold(body: Center(child: CircularProgressIndicator())), + ); +} + /// 약관 칸 — #545454 획 · 모서리 8 · 좌우 16. class _Box extends StatelessWidget { const _Box({required this.child}); diff --git a/app/lib/main.dart b/app/lib/main.dart index 4307e62..ef074c8 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -1,14 +1,37 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:flutter/material.dart'; import 'app.dart'; +import 'core/data/backend.dart'; import 'core/firebase/firebase_boot.dart'; import 'features/auth/auth_repository.dart'; import 'features/auth/firebase_auth_session.dart'; +import 'features/auth/signup_screens.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); // 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고. await FirebaseBoot.init(); - final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null; - runApp(FancakeApp(auth: AuthRepository(session: session))); + final live = FirebaseBoot.isLive; + runApp( + FancakeApp( + auth: AuthRepository( + session: live ? FirebaseAuthSession() : null, + needsTerms: live ? _needsTerms : null, + recordTerms: live + ? (agreed) => Backend().call('fnSignupConsent', { + 'agreed': agreed.toList(), + 'version': signupTermsVersion, + }) + : null, + ), + ), + ); +} + +/// 동의 기록(`termsAgreedAt`)이 없으면 약관을 받아야 한다. 갓 생성된 계정은 트리거가 아직 +/// 사용자 문서를 만들지 않았을 수 있다 — 그래도 동의가 필요하다. +Future _needsTerms(String uid) async { + final snap = await FirebaseFirestore.instance.doc('users/$uid').get(); + return snap.data()?['termsAgreedAt'] == null; } diff --git a/app/test/signup_terms_test.dart b/app/test/signup_terms_test.dart new file mode 100644 index 0000000..1f0cd47 --- /dev/null +++ b/app/test/signup_terms_test.dart @@ -0,0 +1,146 @@ +import 'dart:async'; + +import 'package:fancake/app.dart'; +import 'package:fancake/core/firebase/firebase_boot.dart'; +import 'package:fancake/features/auth/auth.dart'; +import 'package:fancake/features/auth/auth_repository.dart'; +import 'package:fancake/features/auth/login_screen.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 가입 약관 — 동의 기록이 없는 계정은 HOME 대신 약관으로 가고, 동의를 기록한 뒤에만 HOME 에 들어간다. +void main() { + setUp(() { + FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '가입 약관 위젯 테스트'); + }); + + const newUser = AuthAccount( + uid: 'new-user', + provider: AuthProviderKind.kakao, + ); + + testWidgets('동의 없는 새 계정은 약관 → 필수 동의 → HOME', (tester) async { + final signIn = Completer(); + final provider = _Provider(AuthProviderKind.kakao, () => signIn.future); + Set? recorded; + final auth = AuthRepository( + providers: [provider], + releaseMode: false, + needsTerms: (_) async => recorded == null, + recordTerms: (agreed) async => recorded = agreed, + ); + await tester.pumpWidget(FancakeApp(auth: auth)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '로그인')); + await tester.pumpAndSettle(); + + await tester.tap(find.text(providerButtonLabel(AuthProviderKind.kakao))); + await tester.pump(); + signIn.complete(const AuthSucceeded(newUser)); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsNothing); + expect(find.text('이용 약관'), findsOneWidget); + expect(find.text('위치기반 서비스 이용약관 (필수)'), findsNothing); + final done = find.widgetWithText(FilledButton, '완료'); + expect(tester.widget(done).onPressed, isNull); + for (final label in [ + '서비스 이용약관 (필수)', + '개인정보 수집·이용 동의 (필수)', + '만 14세 이상입니다. (필수)', + ]) { + await tester.scrollUntilVisible(find.text(label), 100); + await tester.tap(find.text(label)); + await tester.pump(); + } + // 개인정보처리방침은 체크 칸 없는 고지 · 보기 링크. + final policy = find.byKey(const ValueKey('privacy-policy-link')); + await tester.scrollUntilVisible(policy, 200); + await tester.tap(policy); + await tester.pumpAndSettle(); + expect(find.text('개인정보처리방침'), findsOneWidget); + await tester.tap(find.text('확인')); + await tester.pumpAndSettle(); + + expect(tester.widget(done).onPressed, isNotNull); + await tester.tap(done); + await tester.pumpAndSettle(); + + expect(recorded, {'service', 'privacy', 'age'}); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('이미 동의한 계정은 약관 없이 HOME', (tester) async { + final auth = AuthRepository( + providers: [ + _Provider( + AuthProviderKind.google, + () async => const AuthSucceeded(newUser), + ), + ], + releaseMode: false, + needsTerms: (_) async => false, + recordTerms: (_) async => fail('기록하지 않는다'), + ); + await tester.pumpWidget(FancakeApp(auth: auth)); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '로그인')); + await tester.pumpAndSettle(); + await tester.tap(find.text(providerButtonLabel(AuthProviderKind.google))); + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + + testWidgets('가입 모드 — 인증 전에 받은 동의를 기록하고 약관을 다시 묻지 않는다', (tester) async { + Set? recorded; + final auth = AuthRepository( + providers: [ + _Provider( + AuthProviderKind.line, + () async => const AuthSucceeded(newUser), + ), + ], + releaseMode: false, + needsTerms: (_) async => recorded == null, + recordTerms: (agreed) async => recorded = agreed, + ); + await tester.pumpWidget(FancakeApp(auth: auth)); + await tester.pumpAndSettle(); + await tester.tap(find.text('회원가입')); + await tester.pumpAndSettle(); + await tester.tap( + find.text(providerButtonLabel(AuthProviderKind.line, signUp: true)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('전체 동의')); + await tester.pump(); + await tester.tap(find.widgetWithText(FilledButton, '완료')); + await tester.pumpAndSettle(); + + expect(recorded, {'service', 'privacy', 'age', 'marketing'}); + expect(find.byType(NavigationBar), findsOneWidget); + }); +} + +final class _Provider implements SocialAuthProvider { + _Provider(this.provider, this._signIn); + + @override + final AuthProviderKind provider; + final Future Function() _signIn; + int calls = 0; + + @override + bool get isLive => true; + + @override + List get missingConfig => const []; + + @override + Future signIn() { + calls += 1; + return _signIn(); + } + + @override + Future signOut() async {} +} From c42fd615a6c9af65ff32893cf606bfc63a5842fc Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Sun, 11 Oct 2026 23:43:43 +0900 Subject: [PATCH 12/19] =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=EC=A7=84=ED=96=89=20=EC=A4=91=20=EB=A1=9C=EB=94=A9?= =?UTF-8?q?=20=ED=91=9C=EC=8B=9C=20=C2=B7=20=EC=A4=91=EB=B3=B5=20=EC=9E=85?= =?UTF-8?q?=EB=A0=A5=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/auth/login_screen.dart | 8 +++++++- app/test/signup_terms_test.dart | 16 ++++++++++++++-- 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/app/lib/features/auth/login_screen.dart b/app/lib/features/auth/login_screen.dart index 548af81..1226eb5 100644 --- a/app/lib/features/auth/login_screen.dart +++ b/app/lib/features/auth/login_screen.dart @@ -209,6 +209,7 @@ class _SimpleLoginScreenState extends State { bool _busy = false; Future _signIn(SocialProvider provider) async { + if (_busy) return; if (widget.signUp) { final agreed = await Navigator.of(context).push>( MaterialPageRoute(builder: (_) => const TermsScreen()), @@ -217,6 +218,8 @@ class _SimpleLoginScreenState extends State { // 인증 뒤 동의가 필요한 계정이면 이 동의를 서버에 기록한다(약관을 다시 묻지 않는다). widget.auth.pendingConsent = agreed; } + // 제공자 화면에서 돌아온 뒤에도 서버 토큰 교환 · 로그인에 몇 초 걸린다(Kakao · LINE). + // 끝날 때까지 화면 전체를 막고 진행 표시를 띄운다 — 다시 누르지 못하게. setState(() => _busy = true); final outcome = await widget.auth.signIn(provider); if (outcome is! AuthSucceeded) widget.auth.pendingConsent = null; @@ -348,7 +351,10 @@ class _SimpleLoginScreenState extends State { ), ), ); - return scaffold; + return PopScope( + canPop: !_busy, + child: Stack(children: [scaffold, if (_busy) const BlockingProgress()]), + ); } } diff --git a/app/test/signup_terms_test.dart b/app/test/signup_terms_test.dart index 1f0cd47..6a0fc2f 100644 --- a/app/test/signup_terms_test.dart +++ b/app/test/signup_terms_test.dart @@ -5,10 +5,12 @@ import 'package:fancake/core/firebase/firebase_boot.dart'; import 'package:fancake/features/auth/auth.dart'; import 'package:fancake/features/auth/auth_repository.dart'; import 'package:fancake/features/auth/login_screen.dart'; +import 'package:fancake/features/auth/signup_screens.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; /// 가입 약관 — 동의 기록이 없는 계정은 HOME 대신 약관으로 가고, 동의를 기록한 뒤에만 HOME 에 들어간다. +/// 소셜 로그인 진행 중에는 화면 전체를 막고 진행 표시를 띄운다. void main() { setUp(() { FirebaseBoot.debugSetMode(FirebaseMode.mock, reason: '가입 약관 위젯 테스트'); @@ -19,7 +21,9 @@ void main() { provider: AuthProviderKind.kakao, ); - testWidgets('동의 없는 새 계정은 약관 → 필수 동의 → HOME', (tester) async { + testWidgets('로그인 진행 중 로딩 · 중복 입력 막음 → 동의 없는 새 계정은 약관 → 필수 동의 → HOME', ( + tester, + ) async { final signIn = Completer(); final provider = _Provider(AuthProviderKind.kakao, () => signIn.future); Set? recorded; @@ -34,8 +38,15 @@ void main() { await tester.tap(find.widgetWithText(FilledButton, '로그인')); await tester.pumpAndSettle(); - await tester.tap(find.text(providerButtonLabel(AuthProviderKind.kakao))); + final kakao = find.text(providerButtonLabel(AuthProviderKind.kakao)); + await tester.tap(kakao); await tester.pump(); + expect(find.byType(BlockingProgress), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + await tester.tap(kakao, warnIfMissed: false); + await tester.pump(); + expect(provider.calls, 1); + signIn.complete(const AuthSucceeded(newUser)); await tester.pumpAndSettle(); expect(find.byType(NavigationBar), findsNothing); @@ -67,6 +78,7 @@ void main() { expect(recorded, {'service', 'privacy', 'age'}); expect(find.byType(NavigationBar), findsOneWidget); + expect(find.byType(BlockingProgress), findsNothing); }); testWidgets('이미 동의한 계정은 약관 없이 HOME', (tester) async { From 6e0aa5d43cce951bbccf04e44830212973b2220a Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 12 Oct 2026 00:03:46 +0900 Subject: [PATCH 13/19] =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=97=90=20=EA=B0=9C=EC=9D=B8=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=B9=A8=20=EA=B3=A0?= =?UTF-8?q?=EC=A7=80=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20=EB=A7=81=ED=81=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/auth/signup_screens.dart | 61 +++++++++++++---------- app/test/signup_terms_test.dart | 38 +++++++++++++- 2 files changed, 71 insertions(+), 28 deletions(-) diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index a440364..8fbba27 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -13,7 +13,7 @@ import 'auth_repository.dart'; /// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상. /// - 선택: 마케팅 수신(기본 OFF). /// - 위치기반 서비스 약관은 여기서 받지 않는다 — 위치기능 최초 사용 직전에 따로 받는다. -/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 목록 끝에 안내와 보기 링크만 둔다. +/// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 하단 버튼 위에 안내와 보기 링크만 둔다. /// /// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 /// [termsPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). @@ -143,7 +143,39 @@ class _TermsScreenState extends State { bottomNavigationBar: SafeArea( child: Padding( padding: const EdgeInsets.all(AppSpace.s4), - child: _buttons(), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다. + // 목록 끝에 두면 iPhone 11 높이에서 하단 버튼 뒤로 가려지므로 버튼 위에 고정한다. + Padding( + key: const ValueKey('privacy-policy-notice'), + padding: const EdgeInsets.only(bottom: AppSpace.s2), + child: Row( + children: [ + const Expanded( + child: Text( + '개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.', + style: TextStyle( + fontFamily: AppFonts.display, + fontFamilyFallback: [AppFonts.body], + fontSize: 12, + height: 1.36, + color: AppColors.onCosmicMuted, + ), + ), + ), + TextButton( + key: const ValueKey('privacy-policy-link'), + onPressed: _showPrivacyPolicy, + child: const Text('보기'), + ), + ], + ), + ), + _buttons(), + ], + ), ), ), body: ListView( @@ -206,31 +238,6 @@ class _TermsScreenState extends State { ), ), ], - // 개인정보처리방침 — 동의 항목과 구분한 고지. 체크 칸이 없고 「보기」로 본문을 연다. - Padding( - padding: const EdgeInsets.symmetric(vertical: AppSpace.s4), - child: Row( - children: [ - const Expanded( - child: Text( - '개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.', - style: TextStyle( - fontFamily: AppFonts.display, - fontFamilyFallback: [AppFonts.body], - fontSize: 12, - height: 1.36, - color: AppColors.onCosmicMuted, - ), - ), - ), - TextButton( - key: const ValueKey('privacy-policy-link'), - onPressed: _showPrivacyPolicy, - child: const Text('보기'), - ), - ], - ), - ), ], ), ); diff --git a/app/test/signup_terms_test.dart b/app/test/signup_terms_test.dart index 6a0fc2f..df5d4e5 100644 --- a/app/test/signup_terms_test.dart +++ b/app/test/signup_terms_test.dart @@ -65,7 +65,6 @@ void main() { } // 개인정보처리방침은 체크 칸 없는 고지 · 보기 링크. final policy = find.byKey(const ValueKey('privacy-policy-link')); - await tester.scrollUntilVisible(policy, 200); await tester.tap(policy); await tester.pumpAndSettle(); expect(find.text('개인정보처리방침'), findsOneWidget); @@ -81,6 +80,43 @@ void main() { expect(find.byType(BlockingProgress), findsNothing); }); + testWidgets('개인정보처리방침 고지는 스크롤 없이 하단 버튼 위에 보이고 체크 칸이 없다', (tester) async { + // iPhone 11 — 414x896pt · 상단 48 · 하단 34. + tester.view.physicalSize = const Size(828, 1792); + tester.view.devicePixelRatio = 2; + tester.view.padding = const FakeViewPadding(top: 96, bottom: 68); + addTearDown(tester.view.reset); + await tester.pumpWidget(const MaterialApp(home: TermsScreen())); + await tester.pumpAndSettle(); + + final notice = find.byKey(const ValueKey('privacy-policy-notice')); + expect( + find.descendant( + of: notice, + matching: find.text('개인정보 처리 내용은 개인정보처리방침에서 확인할 수 있습니다.'), + ), + findsOneWidget, + ); + final link = find.descendant(of: notice, matching: find.text('보기')); + expect(link, findsOneWidget); + expect( + find.descendant(of: notice, matching: find.byType(ImageIcon)), + findsNothing, + ); + // 화면 안 · 「이전」 버튼 위. + expect(tester.getRect(link).bottom, lessThanOrEqualTo(896 - 34)); + expect( + tester.getRect(notice).bottom, + lessThanOrEqualTo( + tester.getRect(find.widgetWithText(FilledButton, '이전')).top, + ), + ); + + await tester.tap(link); + await tester.pumpAndSettle(); + expect(find.text('개인정보처리방침'), findsOneWidget); + }); + testWidgets('이미 동의한 계정은 약관 없이 HOME', (tester) async { final auth = AuthRepository( providers: [ From 3511ba2de97c458384422bbccb41139b93ecea7b Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 12 Oct 2026 04:07:04 +0900 Subject: [PATCH 14/19] =?UTF-8?q?=EC=95=BD=EA=B4=80=20=EB=8F=99=EC=9D=98?= =?UTF-8?q?=20=ED=99=94=EB=A9=B4=EC=9D=98=20=EA=B0=9C=EC=9D=B8=EC=A0=95?= =?UTF-8?q?=EB=B3=B4=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=B9=A8=20=EB=B3=B4?= =?UTF-8?q?=EA=B8=B0=EC=97=90=20=EC=B2=98=EB=A6=AC=EB=B0=A9=EC=B9=A8=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=EB=AC=B8=EA=B5=AC=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/features/auth/signup_screens.dart | 65 +++++++++++++++++------ app/test/signup_terms_test.dart | 18 +++++++ 2 files changed, 68 insertions(+), 15 deletions(-) diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index 8fbba27..5d9e270 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -16,7 +16,7 @@ import 'auth_repository.dart'; /// - 개인정보처리방침은 동의 항목이 아니라 고지다 — 체크 칸 없이 하단 버튼 위에 안내와 보기 링크만 둔다. /// /// 약관 본문은 확정 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 -/// [termsPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). +/// 항목별 본문 · [privacyPolicyPlaceholderBody] 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다). class TermsScreen extends StatefulWidget { const TermsScreen({super.key, this.onAgreed, this.onBack, this.busy = false}); @@ -38,22 +38,53 @@ const signupTermsVersion = 'draft-1'; List get signupTermKeys => [for (final t in _Term.values) t.name]; enum _Term { - service('서비스 이용약관 (필수)', required: true, body: true), - privacy('개인정보 수집·이용 동의 (필수)', required: true, body: true), - age('만 14세 이상입니다. (필수)', required: true, body: false), - marketing('마케팅 정보 및 이벤트 알림 수신 동의 (선택)', required: false, body: true); + service('서비스 이용약관 (필수)', required: true, body: _serviceBody), + privacy('개인정보 수집·이용 동의 (필수)', required: true, body: _privacyBody), + age('만 14세 이상입니다. (필수)', required: true), + marketing( + '마케팅 정보 및 이벤트 알림 수신 동의 (선택)', + required: false, + body: _marketingBody, + ); - const _Term(this.label, {required this.required, required this.body}); + const _Term(this.label, {required this.required, this.body}); final String label; final bool required; - final bool body; + + /// 항목 아래에 보이는 본문. 없으면 체크 줄만 그린다. + final String? body; } -/// 약관 본문 자리 표시 문구. -const termsPlaceholderBody = +// 약관·방침 본문 자리 표시 문구 — 항목마다 제목이 다르다(한 문구를 돌려 쓰면 방침 창에 약관 제목이 뜬다). +// 정식 문안을 받으면 이 문구와 [signupTermsVersion] 을 함께 바꾼다. +const _serviceBody = 'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n' '추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.'; +const _privacyBody = + 'FANUZU 개인정보 수집·이용 동의\n\n정식 문안을 받기 전 임시 문구입니다.\n\n' + '수집 항목\n정식 문안을 받으면 기재합니다.\n\n' + '이용 목적\n정식 문안을 받으면 기재합니다.\n\n' + '보유 및 이용 기간\n정식 문안을 받으면 기재합니다.\n\n' + '동의를 거부할 권리와 거부 시 불이익\n정식 문안을 받으면 기재합니다.'; +const _marketingBody = + 'FANUZU 마케팅 정보 수신 동의\n\n정식 문안을 받기 전 임시 문구입니다.\n\n' + '수신 내용\n정식 문안을 받으면 기재합니다.\n\n' + '수신 방법\n정식 문안을 받으면 기재합니다.\n\n' + '보유 및 이용 기간\n정식 문안을 받으면 기재합니다.\n\n' + '동의 철회 방법\n정식 문안을 받으면 기재합니다.'; + +/// 개인정보처리방침 본문 자리 표시 문구 — 동의 항목이 아니라 하단 고지의 「보기」로 연다. +const privacyPolicyPlaceholderBody = + 'FANUZU 개인정보처리방침\n\n정식 문안을 받기 전 임시 문구입니다.\n\n' + '1. 수집하는 개인정보 항목\n정식 문안을 받으면 기재합니다.\n\n' + '2. 개인정보의 이용 목적\n정식 문안을 받으면 기재합니다.\n\n' + '3. 개인정보의 보유 및 이용 기간\n정식 문안을 받으면 기재합니다.\n\n' + '4. 개인정보 처리 위탁 및 국외 이전\n정식 문안을 받으면 기재합니다.\n\n' + '5. 개인정보의 파기\n정식 문안을 받으면 기재합니다.\n\n' + '6. 이용자의 권리와 행사 방법\n정식 문안을 받으면 기재합니다.\n\n' + '7. 개인정보 보호책임자 및 문의처\n정식 문안을 받으면 기재합니다.\n\n' + '8. 시행일\n정식 문안을 받으면 기재합니다.'; const _stroke = Color(0xFF545454); @@ -79,7 +110,9 @@ class _TermsScreenState extends State { context: context, builder: (context) => AlertDialog( title: const Text('개인정보처리방침'), - content: const SingleChildScrollView(child: Text(termsPlaceholderBody)), + content: const SingleChildScrollView( + child: Text(privacyPolicyPlaceholderBody), + ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), @@ -194,7 +227,7 @@ class _TermsScreenState extends State { for (final t in _Term.values) ...[ const SizedBox(height: AppSpace.s3), _Box( - child: t.body + child: t.body != null ? Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ @@ -209,15 +242,17 @@ class _TermsScreenState extends State { onTap: () => _toggle(t), ), ), - const Padding( - padding: EdgeInsets.symmetric(vertical: AppSpace.s4), + Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpace.s4, + ), child: SizedBox( height: 80, child: ClipRect( child: Text( - termsPlaceholderBody, + t.body!, overflow: TextOverflow.clip, - style: TextStyle( + style: const TextStyle( fontFamily: AppFonts.display, fontFamilyFallback: [AppFonts.body], fontSize: 12, diff --git a/app/test/signup_terms_test.dart b/app/test/signup_terms_test.dart index df5d4e5..a092e66 100644 --- a/app/test/signup_terms_test.dart +++ b/app/test/signup_terms_test.dart @@ -117,6 +117,24 @@ void main() { expect(find.text('개인정보처리방침'), findsOneWidget); }); + testWidgets('개인정보처리방침 보기는 약관 문구가 아닌 처리방침 문구를 보여 준다', (tester) async { + await tester.pumpWidget(const MaterialApp(home: TermsScreen())); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('privacy-policy-link'))); + await tester.pumpAndSettle(); + + final body = tester + .widget( + find.descendant( + of: find.byType(AlertDialog), + matching: find.textContaining('임시 문구'), + ), + ) + .data!; + expect(body, contains('개인정보처리방침')); + expect(body, isNot(contains('서비스 이용약관'))); + }); + testWidgets('이미 동의한 계정은 약관 없이 HOME', (tester) async { final auth = AuthRepository( providers: [ From 3b2bb5435ec28be74555563d37d6cd56ad77d58a Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 12 Oct 2026 04:12:08 +0900 Subject: [PATCH 15/19] =?UTF-8?q?1=EC=B0=A8=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B2=84=EC=A0=84=200.1.1+12?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 26a91c6..c79e62b 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: fancake description: "FANUZU — 팬덤 플랫폼 앱 (골격). 발주사 (주)팬케이크 / 수행사 스위치 주식회사" publish_to: 'none' -version: 0.1.1+11 +version: 0.1.1+12 environment: sdk: ^3.11.5 From 59a8f9ead84ef3e6f374def2c679982921fd9f29 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 12 Oct 2026 13:55:46 +0900 Subject: [PATCH 16/19] =?UTF-8?q?1=EC=B0=A8=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B2=84=EC=A0=84=200.1.1+13?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index c79e62b..3812d60 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: fancake description: "FANUZU — 팬덤 플랫폼 앱 (골격). 발주사 (주)팬케이크 / 수행사 스위치 주식회사" publish_to: 'none' -version: 0.1.1+12 +version: 0.1.1+13 environment: sdk: ^3.11.5 From de65316205f74bead82a6acf85a52ac2ccb117c9 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 12 Oct 2026 15:02:43 +0900 Subject: [PATCH 17/19] =?UTF-8?q?Android=20LINE=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20=ED=9B=84=20=EC=95=B1=EC=9C=BC=EB=A1=9C=20=EB=8F=8C?= =?UTF-8?q?=EC=95=84=EC=98=A4=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EB=AC=B8?= =?UTF-8?q?=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/android/app/src/main/AndroidManifest.xml | 1 - app/test/store_readiness_test.dart | 12 ++++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml index 177d060..1ad7e7f 100644 --- a/app/android/app/src/main/AndroidManifest.xml +++ b/app/android/app/src/main/AndroidManifest.xml @@ -7,7 +7,6 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTop" - android:taskAffinity="" android:theme="@style/LaunchTheme" android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:hardwareAccelerated="true" diff --git a/app/test/store_readiness_test.dart b/app/test/store_readiness_test.dart index bd59af9..cc89e99 100644 --- a/app/test/store_readiness_test.dart +++ b/app/test/store_readiness_test.dart @@ -69,6 +69,18 @@ void main() { }); }); + test('Android — MainActivity 가 패키지 기본 task 에 있어 LINE 로그인 후 앱으로 돌아온다', () { + // LINE SDK 는 lineauth:// 복귀 시 결과 화면을 NEW_TASK|CLEAR_TOP 으로 띄워 패키지 + // affinity 의 task 를 찾는다. MainActivity 의 taskAffinity 가 비어 있으면 새 task 가 + // 생겨 Chrome 맞춤 탭이 앱 위에 남는다(사용자가 X 를 눌러야 로그인이 끝난다). + final manifest = _read('android/app/src/main/AndroidManifest.xml'); + final mainActivity = RegExp( + r']*android:name="\.MainActivity"[^>]*>', + ).firstMatch(manifest)?.group(0); + expect(mainActivity, isNotNull); + expect(mainActivity, isNot(contains('taskAffinity'))); + }); + test('설정·서명 자산을 git 이 추적하지 않는다', () { final result = Process.runSync('git', [ 'ls-files', From 0d2394c002344de6a1442f8491746c0f8910a09c Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Mon, 12 Oct 2026 15:02:43 +0900 Subject: [PATCH 18/19] =?UTF-8?q?1=EC=B0=A8=20=EB=B3=B4=EC=99=84=20?= =?UTF-8?q?=EB=B9=8C=EB=93=9C=20=EB=B2=84=EC=A0=84=200.1.1+14?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/pubspec.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 3812d60..5d52ffe 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -1,7 +1,7 @@ name: fancake description: "FANUZU — 팬덤 플랫폼 앱 (골격). 발주사 (주)팬케이크 / 수행사 스위치 주식회사" publish_to: 'none' -version: 0.1.1+13 +version: 0.1.1+14 environment: sdk: ^3.11.5 From ec84a9da437fc1708e6aac9eabd569e720501c92 Mon Sep 17 00:00:00 2001 From: fanuzu Date: Tue, 13 Oct 2026 13:01:39 +0900 Subject: [PATCH 19/19] docs: clarify monthly nebula reset and per-member firepower denominator --- docs/FANUZU_기준문서_추가확정_20261013.md | 32 +++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 docs/FANUZU_기준문서_추가확정_20261013.md diff --git a/docs/FANUZU_기준문서_추가확정_20261013.md b/docs/FANUZU_기준문서_추가확정_20261013.md new file mode 100644 index 0000000..ebb61ef --- /dev/null +++ b/docs/FANUZU_기준문서_추가확정_20261013.md @@ -0,0 +1,32 @@ +# FANUZU 기준문서 추가 확정 + +- 확정일: 2026-10-13 (KST) +- 기준: 통합검토회신 v2.3, 1장 10번·11장 성운 월 제한 및 Q-A23 +- PDF 반영 위치: 통합검토회신 v2.3의 추가 확정 26쪽 + +## 1. 성운 생성 월 1회 + +사용자당 성운 생성은 한국 표준시(KST)의 달력상 월을 기준으로 월 1회입니다. 매월 1일 00:00 KST에 해당 월 생성 횟수를 초기화합니다. 마지막 생성 시점부터 30일을 계산하는 방식은 적용하지 않습니다. + +예: 10월 31일 생성 후 11월 1일 00:00 KST부터 새 월의 1회 생성이 가능합니다. + +## 2. 1인당 화력 분모 + +Q-A23의 “현재 유효 멤버수”는 동일 기준시각에 해당 행성(팬덤)에 가입되어 있는 멤버 수로 정의하며, 해당 기준시각 이전에 탈퇴한 멤버는 제외합니다. 최근 30일 활동 여부로 분모를 제한하지 않습니다. + +Q-A03의 Active(최근 30일 해당 팬덤에서 서버 인정 활동 1건 이상 사용자)는 별도 지표이며, Q-A23의 1인당 화력 분모에 사용하지 않습니다. + +**1인당 화력 = 해당 집계기간의 유효 Raw FPI 점수 / 동일 기준시각의 가입 멤버 수** + +- 분자는 백분위 변환 전 점수를 사용합니다. +- 분모가 0이면 산정불가로 표시합니다. +- 표시용 백분위 0~100을 인원수로 나누거나 POP 획득·소비 금액을 합산하지 않습니다. +- 예: 유효 Raw FPI 1,000점, 가입 멤버 100명, 최근 30일 활동자 40명이면 1인당 화력은 10점입니다. 탈퇴자는 가입 멤버 100명에 포함하지 않습니다. + +## 적용 및 변경 이력 + +이번 확정은 기존 “월 1회”의 기간 경계와 “현재 유효 멤버수”의 정의를 명확히 합니다. 두 항목의 해석에는 본 추가 확정 내용을 우선 적용합니다. 관련 명세·화면 설명·검수 기준에 동일하게 반영합니다. + +## 문서 관리 원칙 + +발주자 요청(2026-10-13)에 따라 이후 기준문서 변경 시 GitHub에도 같은 확정 내용을 함께 반영하고 변경 이력을 남깁니다.