SNS 계정 연결의 이메일 일치 조건 제거 — 현재 로그인과 새 제공자 인증을 서버에서 확인 · 다른 계정에 연결된 제공자는 거부 · 세션 만료 시 재로그인 안내

This commit is contained in:
Sn-Kinos
2026-10-10 21:14:49 +09:00
parent b66863a149
commit 4d2ce67886
8 changed files with 175 additions and 81 deletions
+27 -11
View File
@@ -35,7 +35,7 @@ export interface KakaoProfile {
/** 카카오 회원번호. */
id: string;
nickname?: string;
/** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */
/** 동의했고 유효·인증된 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */
email?: string;
}
@@ -110,25 +110,40 @@ export async function resolveKakaoUid(p: KakaoProfile): Promise<string> {
}
/**
* 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<void> {
export async function linkKakao(uid: string, p: KakaoProfile, authTime?: number): Promise<void> {
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<string, unknown>).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);
+1 -1
View File
@@ -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;