133 lines
6.5 KiB
TypeScript
133 lines
6.5 KiB
TypeScript
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) };
|
|
}
|