1차 보완(0.1.1+14) 내용 합치기
This commit is contained in:
@@ -26,7 +26,7 @@ export const fnAuthOnCreate = functionsV1.region('asia-northeast3').auth.user().
|
||||
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||
// 이메일 — 10/5 「플랫폼이 이메일 제공을 승인·동의한 경우에만 수집, 없어도 가입·로그인 가능」.
|
||||
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `resolveKakaoUid` 가 따로 쓴다.
|
||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
|
||||
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
||||
const defaults: Record<string, unknown> = {
|
||||
uid: user.uid,
|
||||
@@ -436,36 +436,73 @@ export const fnPlanetCreate = onCall(async (req) => {
|
||||
});
|
||||
|
||||
/**
|
||||
* `fn-consent-record` — 약관 동의 이력.
|
||||
* 가입 이후 따로 받는 동의 — 위치기반 서비스(Q-E1 · 1차 검수 R02 「위치기능 최초 사용 전」)와 마케팅 수신.
|
||||
* 가입 필수 항목(서비스 · 개인정보 수집·이용 · 만 14세)은 여기서 철회하지 않는다(탈퇴로만 끝난다).
|
||||
*/
|
||||
export const LATER_CONSENTS = ['location', 'marketing'] as const;
|
||||
|
||||
/**
|
||||
* `fn-consent-record` — 약관 동의 · 철회 이력.
|
||||
*
|
||||
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다.
|
||||
* append-only 다. 철회도 기존 행을 고치지 않고 `granted: false` 인 새 행으로 남긴다 — 행 ID 는
|
||||
* `{항목}_{버전}_{grant|revoke}_{시각}` 이라 같은 항목을 동의 → 철회 → 재동의해도 앞 행이 덮이지 않는다.
|
||||
* 동의 시점을 되짚을 수 없으면 분쟁에서 증명할 수단이 사라진다.
|
||||
*
|
||||
* 지금 상태는 사용자 문서 `consentState.{항목}`(서버만 쓴다 — 규칙의 본인 수정 목록에 없음)에 함께 둔다.
|
||||
* 위치 Quest 인증(`fnQuestVerifyPlace`)이 이 값으로 동의 여부를 본다.
|
||||
*/
|
||||
export const fnConsentRecord = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
const docType = str(req.data, 'docType');
|
||||
const docVersion = str(req.data, 'docVersion');
|
||||
if (!(LATER_CONSENTS as readonly string[]).includes(docType)) {
|
||||
throw new HttpsError('invalid-argument', '기록할 수 없는 동의 항목입니다.');
|
||||
}
|
||||
const granted = (req.data as Record<string, unknown>)?.granted === true;
|
||||
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}`;
|
||||
|
||||
await getFirestore().doc(`users/${uid}/consents/${consentId}`).set({
|
||||
const consentId = `${docType}_${docVersion}_${granted ? 'grant' : 'revoke'}_${Date.now()}`;
|
||||
const db = getFirestore();
|
||||
const batch = db.batch();
|
||||
batch.set(db.doc(`users/${uid}/consents/${consentId}`), {
|
||||
docType,
|
||||
docVersion,
|
||||
granted,
|
||||
ip: req.rawRequest?.ip ?? null,
|
||||
userAgent: req.rawRequest?.headers?.['user-agent'] ?? null,
|
||||
createdAt: FieldValue.serverTimestamp(),
|
||||
});
|
||||
batch.set(db.doc(`users/${uid}`), {
|
||||
consentState: { [docType]: { granted, version: docVersion, at: FieldValue.serverTimestamp() } },
|
||||
// 마케팅은 Push Marketing 구분과 같은 값이다(가입 때와 같은 규칙).
|
||||
...(docType === 'marketing' ? { notificationPrefs: { marketing: granted } } : {}),
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
|
||||
return { consentId };
|
||||
await batch.commit();
|
||||
return { consentId, granted };
|
||||
});
|
||||
|
||||
/**
|
||||
* 가입 약관 필수 항목 — Figma `179:24230`(서비스 이용약관 · 위치기반 서비스 · 만 14세 이상)에
|
||||
* 10/3 통합회신 3절 4 · 10/5 「개인정보처리방침을 약관과 함께 전달」의 개인정보 처리방침을 더했다.
|
||||
* 항목 구성은 해석 확인 요청 E-Q1(회신 대기). 마케팅 수신은 선택 · 기본 OFF(별첨 B 「Push 알림」).
|
||||
* 위치기반 서비스에 동의한 상태인가. `consentState.location` 이 있으면 그 값(철회 포함)을 따르고,
|
||||
* 없으면 예전 가입 화면(위치 약관이 가입 필수였던 0.2.0 이전)에서 남긴 `location_{가입 약관 버전}_grant`
|
||||
* 행으로 본다 — 이미 동의한 사용자에게 다시 묻지 않는다.
|
||||
*/
|
||||
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] as const;
|
||||
export async function hasLocationConsent(uid: string): Promise<boolean> {
|
||||
const db = getFirestore();
|
||||
const user = (await db.doc(`users/${uid}`).get()).data();
|
||||
const state = (user?.consentState as Record<string, { granted?: unknown }> | undefined)?.location;
|
||||
if (state) return state.granted === true;
|
||||
const version = user?.termsVersion;
|
||||
if (typeof version !== 'string' || !version) return false;
|
||||
return (await db.doc(`users/${uid}/consents/location_${version}_grant`).get()).exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* 가입 약관 필수 항목 — 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02**.
|
||||
*
|
||||
* 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상 확인만 가입 필수다. **위치기반 서비스 약관은
|
||||
* 가입 때 받지 않는다** — 위치기능을 처음 쓰기 직전에 따로 받는다([fnConsentRecord] `location`).
|
||||
* 개인정보처리방침은 동의 항목이 아니라 고지(링크)라 기록하지 않는다. 마케팅 수신은 선택 · 기본 OFF.
|
||||
*/
|
||||
export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'age'] as const;
|
||||
|
||||
/**
|
||||
* `fn-signup-consent` — 가입 약관 동의를 한 번에 기록한다(FR-CMN-04 · 원장 C-134).
|
||||
@@ -474,6 +511,8 @@ export const SIGNUP_REQUIRED_TERMS = ['service', 'privacy', 'location', 'age'] a
|
||||
* 찍는다. 앱은 이 값이 없으면 로그인 직후 온보딩(약관부터)으로 보낸다 — 「로그인」 버튼으로 처음
|
||||
* 들어온 소셜 계정(카카오 커스텀 토큰 포함)이 약관 · 만 14세 확인 없이 지나가지 못하게 하는 기준이다.
|
||||
* 마케팅 동의는 Push Marketing 구분(`notificationPrefs.marketing`)과 같은 값으로 둔다.
|
||||
* 예전 앱(0.2.0+10 이하)은 가입 화면에서 위치 약관을 받아 `location` 을 함께 보낸다. 사용자가 실제로
|
||||
* 동의한 것이라 그대로 기록한다 — 버리면 그 앱으로 가입한 계정은 위치 동의 화면이 없어 위치 Quest 를 못 한다.
|
||||
*/
|
||||
export const fnSignupConsent = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
@@ -497,10 +536,19 @@ export const fnSignupConsent = onCall(async (req) => {
|
||||
batch.set(db.doc(`users/${uid}/consents/${docType}_${version}_${granted ? 'grant' : 'revoke'}`),
|
||||
{ docType, granted, ...meta }, { merge: true });
|
||||
}
|
||||
const legacyLocation = agreed.has('location');
|
||||
if (legacyLocation) {
|
||||
batch.set(db.doc(`users/${uid}/consents/location_${version}_grant`),
|
||||
{ docType: 'location', granted: true, ...meta }, { merge: true });
|
||||
}
|
||||
batch.set(db.doc(`users/${uid}`), {
|
||||
termsAgreedAt: FieldValue.serverTimestamp(),
|
||||
termsVersion: version,
|
||||
notificationPrefs: { marketing },
|
||||
consentState: {
|
||||
marketing: { granted: marketing, version, at: FieldValue.serverTimestamp() },
|
||||
...(legacyLocation ? { location: { granted: true, version, at: FieldValue.serverTimestamp() } } : {}),
|
||||
},
|
||||
updatedAt: FieldValue.serverTimestamp(),
|
||||
}, { merge: true });
|
||||
await batch.commit();
|
||||
|
||||
@@ -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 연동 규칙(Q-E8)은 LINE 과 같아 `social_token.ts` 에 있다.
|
||||
*
|
||||
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
|
||||
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||
@@ -35,7 +35,7 @@ export interface KakaoProfile {
|
||||
/** 카카오 회원번호. */
|
||||
id: string;
|
||||
nickname?: string;
|
||||
/** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */
|
||||
/** 동의했고 유효·인증된 이메일만. 없어도 가입 · 로그인 · 연결이 된다(R01 · Q-E8). */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
@@ -74,80 +74,9 @@ export async function verifyKakaoToken(accessToken: string, appId: string): Prom
|
||||
};
|
||||
}
|
||||
|
||||
/** 이 카카오 계정이 붙은 Firebase UID. 없으면 null. */
|
||||
async function ownerOf(kakaoId: string): Promise<string | null> {
|
||||
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:<id>` 를 만들고 연결한다. */
|
||||
export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
|
||||
const existing = await ownerOf(p.id);
|
||||
if (existing) return existing;
|
||||
// 탈퇴한 계정의 문서(원장 보존)가 `kakao:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
|
||||
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
|
||||
const base = kakaoUidOf(p.id);
|
||||
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
|
||||
const uid = withdrawn ? `${base}:${Date.now()}` : base;
|
||||
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<void> {
|
||||
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) });
|
||||
}
|
||||
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<string, unknown>).link === true) {
|
||||
const uid = req.auth?.uid;
|
||||
if (!uid) fail(ERR.authFailed, '로그인이 필요합니다.');
|
||||
await linkKakao(uid, profile);
|
||||
return { linked: true };
|
||||
}
|
||||
const uid = await resolveKakaoUid(profile);
|
||||
// 동의한 이메일만 사용자 문서에 남긴다(10/5). 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);
|
||||
});
|
||||
|
||||
@@ -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」로 끝난다(10/11 iPhone 11 · iOS 26 실기기 — `disable_ios_auto_login` 을
|
||||
* 붙여도 재현). 앱이 LINE 공식 SDK 로 로그인하고 서버가 그 토큰을 LINE API 로 검증해 커스텀 토큰을 내준다.
|
||||
*
|
||||
* **사용자 식별은 기존 `oidc.line` 연결을 그대로 쓴다** — OIDC 시절 가입자는 같은 UID 로 들어오고, SNS 연동
|
||||
* 화면이 지금처럼 「연결됨」을 그린다. 신규 가입자의 UID 는 `line:<LINE userId>` 다.
|
||||
*
|
||||
* 입력: `{ 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<SocialProfile> {
|
||||
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<string, unknown>;
|
||||
};
|
||||
|
||||
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);
|
||||
});
|
||||
@@ -0,0 +1,132 @@
|
||||
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;
|
||||
/** 동의했고 제공자가 준 이메일만. 없어도 가입 · 로그인 · 연결이 된다(R01 · Q-E8 · C-143). */
|
||||
email?: string | undefined;
|
||||
}
|
||||
|
||||
/** 이 제공자 계정이 붙은 Firebase UID. 없으면 null. */
|
||||
async function ownerOf(spec: SocialSpec, id: string): Promise<string | null> {
|
||||
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, 없으면 `<접두>:<id>` 를 만들고 연결한다. */
|
||||
export async function resolveSocialUid(spec: SocialSpec, p: SocialProfile): Promise<string> {
|
||||
const existing = await ownerOf(spec, p.id);
|
||||
if (existing) return existing;
|
||||
// 탈퇴한 계정의 문서(원장 보존)가 `<접두>:<id>` 에 남아 있으면 같은 UID 로 다시 만들지 않는다 —
|
||||
// 다시 가입한 사람이 탈퇴 전 지갑 · 원장을 물려받는다.
|
||||
const base = `${spec.uidPrefix}:${p.id}`;
|
||||
const withdrawn = (await getFirestore().doc(`users/${base}`).get()).get('status') === 'withdrawn';
|
||||
const uid = withdrawn ? `${base}:${Date.now()}` : base;
|
||||
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] 에 제공자 계정을 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
||||
*
|
||||
* 통합검토회신 v2.3 **Q-E8**(C 안 보완) — 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을
|
||||
* **서버에서 모두** 확인해 붙인다. 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다.
|
||||
* - 새 Provider 자격: 호출부가 제공자 API 로 우리 앱(채널)에서 발급된 유효 토큰인지 먼저 본다.
|
||||
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 · 탈퇴 상태가 아니고 토큰이
|
||||
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
|
||||
* - 그 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
|
||||
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
|
||||
*/
|
||||
export async function linkSocial(
|
||||
spec: SocialSpec, uid: string, p: SocialProfile, authTime?: number,
|
||||
): Promise<void> {
|
||||
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;
|
||||
const withdrawn = (await getFirestore().doc(`users/${uid}`).get()).get('status') === 'withdrawn';
|
||||
if (user.disabled || withdrawn || (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<string, unknown>).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);
|
||||
// 동의한 이메일만 사용자 문서에 남긴다(10/5 · C-143). Auth 이메일은 다른 제공자 계정과 겹치면 생성이
|
||||
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
|
||||
if (profile.email) {
|
||||
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
|
||||
}
|
||||
return { token: await getAuth().createCustomToken(uid) };
|
||||
}
|
||||
@@ -37,7 +37,7 @@ export const ERR = {
|
||||
primaryCooldown: 'E-FDM-04',
|
||||
permissionDenied: 'E-PERM-01',
|
||||
authFailed: 'E-AUTH-03',
|
||||
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
|
||||
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「그 Provider 가 다른 계정에 이미 연결」(Q-E8 — 이메일 비교 없음).
|
||||
authCredentialInUse: 'E-AUTH-08',
|
||||
authAlreadyLinked: 'E-AUTH-09',
|
||||
// 서비스 탈퇴 — SNS 연동이 남아 있으면 「비정상 탈퇴」(Figma `554:24885` · `554:24908`).
|
||||
|
||||
@@ -14,6 +14,7 @@ export {
|
||||
fnFandomJoin, fnFandomLeave, fnPrimaryFandomSet, fnConsentRecord, fnSignupConsent, fnAccountWithdraw, fnAdminSetRole,
|
||||
} from './account/account.js';
|
||||
export { fnAuthKakao } from './account/kakao.js';
|
||||
export { fnAuthLine } from './account/line.js';
|
||||
export {
|
||||
fnEclipseDefend, fnEclipseAggregate, fnEclipseTrigger,
|
||||
fnFpiAggregate, fnLiveAggregate, fnFpiDailySnapshot,
|
||||
|
||||
@@ -11,6 +11,7 @@ import { grantPop, type GrantInput } from '../ledger/ledger.js';
|
||||
import { fandomOfUser, markCompletion, writeFpiEvent } from '../ledger/fpi.js';
|
||||
import { dailyLimitOf, progress, progressWriter, questOf } from './quest.js';
|
||||
import { payFromPool } from './ugc.js';
|
||||
import { hasLocationConsent } from '../account/account.js';
|
||||
|
||||
/**
|
||||
* 검증이 필요한 퀘스트 둘 — Music Shake 와 위치.
|
||||
@@ -213,6 +214,11 @@ const proofPhotoExists = (path: string) => placeProofStorage.exists(path).catch(
|
||||
|
||||
export const fnQuestVerifyPlace = onCall(async (req) => {
|
||||
const uid = callerUid(req);
|
||||
// 위치기반 서비스 약관 동의(Q-E1 · R02) — 가입 때가 아니라 위치기능 최초 사용 전에 받는다. 앱이 먼저
|
||||
// 묻지만 서버도 같은 기준을 지킨다. 좌표를 받기 전에 끊는다(동의 없이 위치를 처리하지 않는다).
|
||||
if (!(await hasLocationConsent(uid))) {
|
||||
fail(ERR.permissionDenied, '위치기반 서비스 이용약관에 동의한 뒤 인증할 수 있습니다.', { reason: 'location-consent' });
|
||||
}
|
||||
const placeId = str(req.data, 'placeId');
|
||||
const lat = num(req.data, 'lat');
|
||||
const lng = num(req.data, 'lng');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,9 @@ import { getFirestore } from 'firebase-admin/firestore';
|
||||
import { getStorage } from 'firebase-admin/storage';
|
||||
|
||||
import { bootAdmin, shutdownAdmin, wipeUser } from './support.js';
|
||||
import { fnAccountWithdraw, fnAuthOnCreate, fnSignupConsent } from '../src/account/account.js';
|
||||
import {
|
||||
fnAccountWithdraw, fnAuthOnCreate, fnConsentRecord, fnSignupConsent, hasLocationConsent,
|
||||
} from '../src/account/account.js';
|
||||
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
|
||||
|
||||
/** 가입 약관 기록(E-04 · E-05) · 계정 생성 트리거의 덮어쓰기 금지 · 국가 미고정(E-13) · 서비스 탈퇴(E-42) · 카카오 이메일(E-02). */
|
||||
@@ -66,20 +68,57 @@ describe('계정 생성 트리거', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('fnSignupConsent', () => {
|
||||
it('필수 하나라도 빠지면 거부', async () => {
|
||||
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'location', 'age'] }));
|
||||
describe('fnSignupConsent — Q-E1 · R02', () => {
|
||||
it('필수(서비스 · 개인정보 수집·이용 · 만 14세) 하나라도 빠지면 거부', async () => {
|
||||
const r = fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'age'] }));
|
||||
expect(await codeOf(r)).toBe('invalid-argument');
|
||||
expect((await db().doc(`users/${U}`).get()).get('termsAgreedAt')).toBeUndefined();
|
||||
});
|
||||
|
||||
it('항목별 행 · termsAgreedAt · 마케팅 = Push Marketing', async () => {
|
||||
await fnSignupConsent.run(as(U, { version: 'v1', agreed: ['service', 'privacy', 'location', 'age'] }));
|
||||
it('위치기반 약관 없이 가입된다 · 항목별 행 · 마케팅 기본 OFF = Push Marketing', 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.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', 'location_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
|
||||
expect(rows).toEqual(['age_v1_grant', 'marketing_v1_revoke', 'privacy_v1_grant', 'service_v1_grant']);
|
||||
expect(await hasLocationConsent(U)).toBe(false);
|
||||
});
|
||||
|
||||
it('예전 앱(0.2.0+10)이 가입 화면에서 받은 location 동의는 기록해 위치 Quest 를 막지 않는다', async () => {
|
||||
await fnSignupConsent.run(as(U, { version: 'v2', agreed: ['service', 'privacy', 'location', 'age'] }));
|
||||
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.id);
|
||||
expect(rows).toContain('location_v2_grant');
|
||||
expect(await hasLocationConsent(U)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fnConsentRecord — 위치기능 최초 사용 전 동의 · 철회', () => {
|
||||
it('동의 → 철회 → 재동의가 각각 행으로 남고(덮어쓰지 않음) 현재 상태를 따른다', async () => {
|
||||
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: true }));
|
||||
expect(await hasLocationConsent(U)).toBe(true);
|
||||
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: false }));
|
||||
expect(await hasLocationConsent(U)).toBe(false);
|
||||
await fnConsentRecord.run(as(U, { docType: 'location', docVersion: 'loc-1', granted: true }));
|
||||
expect(await hasLocationConsent(U)).toBe(true);
|
||||
const rows = (await db().collection(`users/${U}/consents`).get()).docs.map((d) => d.data());
|
||||
expect(rows.map((r) => r.granted).sort()).toEqual([false, true, true]);
|
||||
expect(rows.every((r) => r.docType === 'location' && r.docVersion === 'loc-1' && r.createdAt)).toBe(true);
|
||||
const state = (await db().doc(`users/${U}`).get()).get('consentState.location');
|
||||
expect(state.granted).toBe(true);
|
||||
expect(state.version).toBe('loc-1');
|
||||
});
|
||||
|
||||
it('가입 필수 항목은 이 경로로 철회할 수 없다', async () => {
|
||||
expect(await codeOf(fnConsentRecord.run(as(U, { docType: 'service', docVersion: 'v1', granted: false }))))
|
||||
.toBe('invalid-argument');
|
||||
});
|
||||
|
||||
it('마케팅 철회는 Push Marketing 구분과 같은 값이 된다', async () => {
|
||||
await fnConsentRecord.run(as(U, { docType: 'marketing', docVersion: 'v1', granted: true }));
|
||||
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(true);
|
||||
await fnConsentRecord.run(as(U, { docType: 'marketing', docVersion: 'v1', granted: false }));
|
||||
expect((await db().doc(`users/${U}`).get()).get('notificationPrefs.marketing')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다(Q-E8 · R01)', 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('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다(Q-E8 세션 만료 · 인증 취소)', 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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
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 · 이메일(C-143) · 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<typeof fnAuthLine.run>[0];
|
||||
const call = (data: Record<string, unknown>, uid?: string) =>
|
||||
fnAuthLine.run({ data, ...(uid ? { auth: { uid, token: {} } } : {}) } as Req);
|
||||
const codeOf = async (p: Promise<unknown>) => {
|
||||
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<string, string> }) => {
|
||||
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:<userId> 로 만들고 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 이 줄 때(동의)만 사용자 문서에 남긴다 — 없어도 로그인된다(C-143)', 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');
|
||||
});
|
||||
|
||||
it('탈퇴한 line:<id> 문서가 있으면 새 UID 로 만든다', async () => {
|
||||
await getFirestore().doc(`users/${lineUidOf(LINE_ID)}`).set({ status: 'withdrawn' });
|
||||
const { token } = await call({ idToken: 'good' }) as { token: string };
|
||||
const uid = uidOf(token);
|
||||
expect(uid.startsWith(`${lineUidOf(LINE_ID)}:`)).toBe(true);
|
||||
await getAuth().deleteUser(uid);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SNS 연동', () => {
|
||||
it('로그인 없이 link 는 E-AUTH-03', async () => {
|
||||
expect(await codeOf(call({ idToken: 'good', link: true }))).toBe('E-AUTH-03');
|
||||
});
|
||||
|
||||
it('Google 사용자에 붙인다(이메일이 달라도 · 없어도 — Q-E8) · 다른 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('탈퇴 상태 계정으로는 붙이지 않는다', async () => {
|
||||
await getAuth().createUser({ uid: GOOGLE_UID });
|
||||
await getFirestore().doc(`users/${GOOGLE_UID}`).set({ status: 'withdrawn' });
|
||||
expect(await codeOf(call({ idToken: 'good', link: true }, GOOGLE_UID))).toBe('session');
|
||||
});
|
||||
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -87,7 +87,11 @@ beforeEach(async () => {
|
||||
}
|
||||
const events = await db().collection(`fandoms/${FANDOM}/fpiEvents`).get();
|
||||
await Promise.all(events.docs.map((d) => d.ref.delete()));
|
||||
await db().doc(`users/${UID}`).set({ uid: UID, fandomId: FANDOM, totalContributedPop: 0 });
|
||||
await db().doc(`users/${UID}`).set({
|
||||
uid: UID, fandomId: FANDOM, totalContributedPop: 0,
|
||||
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02). 동의 자체는 아래 별도 시험이 본다.
|
||||
consentState: { location: { granted: true, version: 'v1' } },
|
||||
});
|
||||
// 운영값은 문서로 둔다 — 코드에 박으면 회신마다 재배포다(설계서 §10).
|
||||
await db().doc('config/policy').set({
|
||||
attendPop: 10, stepsGoal: 10_000, stepsPop: 50, adRewardPop: 10, adMultiplier: 2, adDailyLimit: 5,
|
||||
@@ -459,6 +463,22 @@ describe('방문 — S-H-PLACE (fn-quest-verify-place)', () => {
|
||||
const inRange = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.5564, lng: 126.9237 };
|
||||
const farAway = { questId: PLACE_QUEST, placeId: PLACE, lat: 37.4979, lng: 127.0276 };
|
||||
|
||||
it('위치기반 서비스 동의가 없으면 좌표를 처리하지 않고 E-PERM-01 (Q-E1 · R02)', async () => {
|
||||
await db().doc(`users/${UID}`).update({ consentState: FieldValue.delete() });
|
||||
expect(await codeOf(() => call(fnQuestVerifyPlace, inRange))).toBe('E-PERM-01');
|
||||
expect((await db().collection(`users/${UID}/activityLogs`).get()).size).toBe(0);
|
||||
// 철회 상태도 마찬가지다.
|
||||
await db().doc(`users/${UID}`).update({ consentState: { location: { granted: false, version: 'v1' } } });
|
||||
expect(await codeOf(() => call(fnQuestVerifyPlace, inRange))).toBe('E-PERM-01');
|
||||
expect((await db().collection(`users/${UID}/popTransactions`).get()).size).toBe(0);
|
||||
});
|
||||
|
||||
it('예전 가입 화면에서 위치 약관에 동의한 사용자는 다시 묻지 않는다', async () => {
|
||||
await db().doc(`users/${UID}`).update({ consentState: FieldValue.delete(), termsVersion: 'draft-1' });
|
||||
await db().doc(`users/${UID}/consents/location_draft-1_grant`).set({ docType: 'location', granted: true });
|
||||
expect((await call(fnQuestVerifyPlace, inRange)).rewarded).toBe(100);
|
||||
});
|
||||
|
||||
it('반경 밖이면 거부하고 거리를 증거에 남긴다', async () => {
|
||||
expect(await codeOf(
|
||||
() => call(fnQuestVerifyPlace, { ...farAway, proofKind: 'qr', proof: 'tok-far' }),
|
||||
|
||||
@@ -48,6 +48,8 @@ const call = (fn: { run(request: never): unknown }, uid: string, data: Record<st
|
||||
async function user(uid: string, fandom: string, ageHours = 48) {
|
||||
await db().doc(`users/${uid}`).set({
|
||||
fandomId: fandom, primaryFandomId: fandom, nickname: uid, status: 'active',
|
||||
// 위치 Quest 인증은 위치기반 서비스 동의가 있어야 한다(Q-E1 · R02).
|
||||
consentState: { location: { granted: true, version: 'v1' } },
|
||||
createdAt: Timestamp.fromMillis(Date.now() - ageHours * HOUR),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<typeof fnSignupConsent.run>[0];
|
||||
const as = (uid: string, data: Record<string, unknown>) => ({ data, auth: { uid, token: {} } }) as CallReq;
|
||||
const codeOf = async (p: Promise<unknown>) => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user