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'); + }); +});