Kakao·LINE 이메일 선택 수집 — 동의한 이메일만 저장 · 이메일 없이 제공자 식별값으로 가입·로그인
This commit is contained in:
@@ -148,6 +148,10 @@ bool get isMockSessionAvailable => !FirebaseBoot.isLive && !_releaseMode;
|
|||||||
| 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` |
|
| 카카오 | 실 코드 경로 | `KakaoSignInProvider` — 카카오 SDK 로그인 + 서버 `fnAuthKakao` 커스텀 토큰 | 카카오 네이티브 앱 키(`config/kakao.env`) · 서버 `KAKAO_APP_ID` |
|
||||||
| LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 |
|
| LINE | 실 코드 경로 | `OidcSignInProvider` — Firebase OIDC `oidc.line` | Firebase 콘솔 LINE 제공자 등록 |
|
||||||
|
|
||||||
|
카카오 · LINE 의 이메일은 **선택 동의 항목**이다. 이메일을 받지 못하거나 사용자가 동의하지 않아도
|
||||||
|
제공자 고유 식별값(카카오 회원번호 · LINE 사용자 ID)으로 가입 · 로그인되고, 같은 계정으로 다시 들어오면
|
||||||
|
같은 UID 로 로그인된다. 동의한 이메일만 사용자 문서(`users/{uid}.email`)에 남는다.
|
||||||
|
|
||||||
키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지
|
키가 없는 제공자의 버튼에는 **「키 대기」 배지**가 붙고, 누르면 무엇이 없어서 못 쓰는지
|
||||||
**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다.
|
**안내 다이얼로그**가 뜬다. 버튼을 회색으로 잠그지 않는 것은 회색이 사유를 말해 주지 않기 때문이다.
|
||||||
|
|
||||||
|
|||||||
@@ -18,13 +18,18 @@ import { ERR, fail } from '../core/errors.js';
|
|||||||
*/
|
*/
|
||||||
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||||
const db = getFirestore();
|
const db = getFirestore();
|
||||||
const batch = db.batch();
|
const userRef = db.doc(`users/${user.uid}`);
|
||||||
|
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||||
batch.set(db.doc(`users/${user.uid}`), {
|
// 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다.
|
||||||
|
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||||
|
// 계정을 만든 뒤에 연결 정보가 붙으므로 `fnAuthKakao` 가 따로 쓴다.
|
||||||
|
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
||||||
|
const defaults: Record<string, unknown> = {
|
||||||
uid: user.uid,
|
uid: user.uid,
|
||||||
displayName: user.displayName ?? '팬우주 여행자',
|
displayName: user.displayName ?? '팬우주 여행자',
|
||||||
photoUrl: user.photoURL ?? null,
|
photoUrl: user.photoURL ?? null,
|
||||||
countryCode: 'KR',
|
countryCode: 'KR',
|
||||||
|
email,
|
||||||
locale: 'ko',
|
locale: 'ko',
|
||||||
fandomId: '',
|
fandomId: '',
|
||||||
authProviders: user.providerData.map((p) => p.providerId),
|
authProviders: user.providerData.map((p) => p.providerId),
|
||||||
@@ -34,18 +39,25 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
|||||||
notificationPrefs: {},
|
notificationPrefs: {},
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
updatedAt: FieldValue.serverTimestamp(),
|
updatedAt: FieldValue.serverTimestamp(),
|
||||||
}, { merge: true });
|
};
|
||||||
|
const wallet: Record<string, unknown> = {
|
||||||
batch.set(db.doc(`users/${user.uid}/wallet/main`), {
|
|
||||||
activityBalance: 0,
|
activityBalance: 0,
|
||||||
supportBalance: 0,
|
supportBalance: 0,
|
||||||
pendingActivity: 0,
|
pendingActivity: 0,
|
||||||
reconcileStatus: 'ok',
|
reconcileStatus: 'ok',
|
||||||
createdAt: FieldValue.serverTimestamp(),
|
createdAt: FieldValue.serverTimestamp(),
|
||||||
updatedAt: 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<string, unknown>, have: Record<string, unknown> | 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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getAuth } from 'firebase-admin/auth';
|
import { getAuth } from 'firebase-admin/auth';
|
||||||
|
import { getFirestore } from 'firebase-admin/firestore';
|
||||||
import { defineString } from 'firebase-functions/params';
|
import { defineString } from 'firebase-functions/params';
|
||||||
import { onCall } from 'firebase-functions/v2/https';
|
import { onCall } from 'firebase-functions/v2/https';
|
||||||
|
|
||||||
@@ -139,5 +140,10 @@ export const fnAuthKakao = onCall(async (req) => {
|
|||||||
return { linked: true };
|
return { linked: true };
|
||||||
}
|
}
|
||||||
const uid = await resolveKakaoUid(profile);
|
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) };
|
return { token: await getAuth().createCustomToken(uid) };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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<typeof fnAuthKakao.run>[0];
|
||||||
|
const onCreate = (uid: string, extra: Record<string, unknown> = {}) =>
|
||||||
|
(fnAuthOnCreate as unknown as { run: (u: unknown) => Promise<void> })
|
||||||
|
.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');
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user