SNS 계정 연결의 이메일 일치 조건 제거 — 현재 로그인과 새 제공자 인증을 서버에서 확인 · 다른 계정에 연결된 제공자는 거부 · 세션 만료 시 재로그인 안내
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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('이메일이 달라도 · 카카오 이메일이 없어도 연결한다 — 이메일 일치는 소유 확인이 아니다', 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('세션이 폐기된 뒤의 토큰 · 정지된 계정으로는 붙이지 않는다', 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 });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user