Kakao 로그인(카카오 SDK · 서버 커스텀 토큰)과 LINE 로그인(Firebase OIDC) 연결 · SNS 연동 · 인증 취소 처리
This commit is contained in:
@@ -12,7 +12,7 @@
|
||||
"build": "tsc",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore --project demo-fanuzu \"npm test\""
|
||||
"test:emulator": "../test/node_modules/.bin/firebase emulators:exec --config ../firebase.json --only firestore,auth --project demo-fanuzu \"npm test\""
|
||||
},
|
||||
"dependencies": {
|
||||
"firebase-admin": "13.6.0",
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
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';
|
||||
|
||||
/**
|
||||
* `fn-auth-kakao` — 카카오 로그인·SNS 연동을 Firebase 커스텀 토큰으로 잇는다.
|
||||
*
|
||||
* **왜 OIDC 가 아니라 이 경로인가.** Android 에서 Firebase OIDC(`oidc.kakao`) 웹 흐름은
|
||||
* 「카카오톡으로 로그인」을 고르면 카카오톡 앱으로 넘어갔다가 콜백이 Custom Tab 이 아닌 일반
|
||||
* Chrome 에서 열려 Firebase 핸들러가 「missing initial state」로 끝난다. 카카오에는 앱 전환을
|
||||
* 막는 파라미터가 없어서, 앱이 카카오 공식 SDK 로 로그인하고 서버가 그 토큰을 검증해
|
||||
* 커스텀 토큰을 내준다(Firebase 「Authenticate with Firebase Using a Custom Authentication
|
||||
* System」).
|
||||
*
|
||||
* **사용자 식별은 기존 `oidc.kakao` 연결을 그대로 쓴다.** Firebase 사용자의 제공자 정보에
|
||||
* `oidc.kakao` / 카카오 회원번호(OIDC `sub` 와 같은 값)를 남겨 두고 그것으로 찾는다. 그래서
|
||||
* - OIDC 시절 가입자는 같은 카카오 계정으로 들어오면 **같은 UID** 로 로그인되고,
|
||||
* - SNS 연동 화면(`linkedAccounts`)이 지금처럼 `oidc.kakao` 를 보고 「연결됨」을 그린다.
|
||||
* 신규 가입자의 UID 는 `kakao:<회원번호>` 다(결정적 — 동시 첫 로그인이 겹쳐도 한 명).
|
||||
*
|
||||
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
|
||||
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||
*/
|
||||
|
||||
export const KAKAO_PROVIDER_ID = 'oidc.kakao';
|
||||
|
||||
/** 카카오 Developers 「앱 ID」(숫자). 비밀값이 아니다 — 토큰이 우리 앱에서 발급됐는지 본다. */
|
||||
const kakaoAppId = defineString('KAKAO_APP_ID', { default: '' });
|
||||
|
||||
export interface KakaoProfile {
|
||||
/** 카카오 회원번호. */
|
||||
id: string;
|
||||
nickname?: string;
|
||||
/** 동의했고 유효·인증된 이메일만. 없으면 이메일 비교를 건너뛴다. */
|
||||
email?: string;
|
||||
}
|
||||
|
||||
export const kakaoUidOf = (kakaoId: string) => `kakao:${kakaoId}`;
|
||||
|
||||
/**
|
||||
* 카카오 액세스 토큰을 카카오 API 로 검증한다.
|
||||
*
|
||||
* 1. `/v1/user/access_token_info` — 유효성 + **우리 앱 ID 로 발급됐는가**. 남의 앱에서 받은
|
||||
* 토큰을 들고 와 그 사람으로 로그인하는 토큰 치환을 막는 지점이다.
|
||||
* 2. `/v2/user/me` — 닉네임·이메일. 회원번호가 1 과 같아야 한다.
|
||||
*/
|
||||
export async function verifyKakaoToken(accessToken: string, appId: string): Promise<KakaoProfile> {
|
||||
if (!appId) fail(ERR.authFailed, '카카오 로그인 설정이 끝나지 않았습니다.');
|
||||
const get = async (path: string) => {
|
||||
const res = await fetch(`https://kapi.kakao.com${path}`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
});
|
||||
if (!res.ok) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
|
||||
return (await res.json()) as Record<string, unknown>;
|
||||
};
|
||||
const info = await get('/v1/user/access_token_info');
|
||||
if (String(info.app_id) !== appId) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
|
||||
const me = await get('/v2/user/me');
|
||||
if (String(me.id) !== String(info.id)) fail(ERR.authFailed, '카카오 인증을 확인하지 못했습니다.');
|
||||
|
||||
const account = (me.kakao_account ?? {}) as Record<string, unknown>;
|
||||
const profile = (account.profile ?? {}) as Record<string, unknown>;
|
||||
const email = account.is_email_valid === true && account.is_email_verified === true
|
||||
&& typeof account.email === 'string' ? account.email : undefined;
|
||||
const nickname = typeof profile.nickname === 'string' ? profile.nickname : undefined;
|
||||
return {
|
||||
id: String(info.id),
|
||||
...(nickname ? { nickname } : {}),
|
||||
...(email ? { email } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** 이 카카오 계정이 붙은 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;
|
||||
const uid = kakaoUidOf(p.id);
|
||||
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) });
|
||||
}
|
||||
|
||||
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);
|
||||
return { token: await getAuth().createCustomToken(uid) };
|
||||
});
|
||||
@@ -9,6 +9,9 @@ import { HttpsError } from 'firebase-functions/v2/https';
|
||||
export const ERR = {
|
||||
permissionDenied: 'E-PERM-01',
|
||||
authFailed: 'E-AUTH-03',
|
||||
// 계정 연결(SNS 연동) — 앱 auth 카탈로그와 같은 ID. 08 은 「다른 계정에 이미 연결」·「다른 이메일」 둘 다다.
|
||||
authCredentialInUse: 'E-AUTH-08',
|
||||
authAlreadyLinked: 'E-AUTH-09',
|
||||
} as const;
|
||||
|
||||
export type ErrorCode = (typeof ERR)[keyof typeof ERR];
|
||||
@@ -20,12 +23,14 @@ type Status = 'invalid-argument' | 'failed-precondition' | 'already-exists'
|
||||
const STATUS: Record<ErrorCode, Status> = {
|
||||
'E-PERM-01': 'permission-denied',
|
||||
'E-AUTH-03': 'permission-denied',
|
||||
'E-AUTH-08': 'already-exists',
|
||||
'E-AUTH-09': 'already-exists',
|
||||
};
|
||||
|
||||
/** 카탈로그 ID 를 그대로 실은 오류. `details.code` 가 계약면이다. */
|
||||
export class CatalogError extends HttpsError {
|
||||
constructor(readonly catalogCode: ErrorCode, message: string) {
|
||||
super(STATUS[catalogCode], message, { code: catalogCode });
|
||||
constructor(readonly catalogCode: ErrorCode, message: string, extra: Record<string, unknown> = {}) {
|
||||
super(STATUS[catalogCode], message, { ...extra, code: catalogCode });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,8 +41,8 @@ export class CatalogError extends HttpsError {
|
||||
* => {...}` 형태로는 호출 이후를 도달 불가로 좁히지 못한다. 그러면 호출 뒤의
|
||||
* 코드가 「값이 없을 수도 있다」로 남아 불필요한 널 검사가 번진다.
|
||||
*/
|
||||
export function fail(code: ErrorCode, message: string): never {
|
||||
throw new CatalogError(code, message);
|
||||
export function fail(code: ErrorCode, message: string, extra?: Record<string, unknown>): never {
|
||||
throw new CatalogError(code, message, extra);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -6,4 +6,5 @@ import './core/bootstrap.js';
|
||||
export {
|
||||
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
|
||||
} from './account/account.js';
|
||||
export { fnAuthKakao } from './account/kakao.js';
|
||||
export { fnAdminWrite } from './admin/admin.js';
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { getAuth } from 'firebase-admin/auth';
|
||||
|
||||
import { bootAdmin, shutdownAdmin } from './support.js';
|
||||
import { fnAuthKakao, KAKAO_PROVIDER_ID, kakaoUidOf } from '../src/account/kakao.js';
|
||||
|
||||
/** 카카오 커스텀 토큰 — 토큰 검증 · 기존 oidc.kakao 사용자 매핑 · 신규 UID · SNS 연동. */
|
||||
|
||||
const APP_ID = '123456';
|
||||
const KAKAO_ID = '987654321';
|
||||
const OLD_UID = 'test-kakao-oidc-user';
|
||||
const GOOGLE_UID = 'test-kakao-google-user';
|
||||
|
||||
type Req = Parameters<typeof fnAuthKakao.run>[0];
|
||||
const call = (data: Record<string, unknown>, uid?: string) =>
|
||||
fnAuthKakao.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;
|
||||
}
|
||||
};
|
||||
|
||||
/** 카카오 API 흉내. 토큰 'good' 만 우리 앱 것, 'other-app' 은 남의 앱 토큰이다. */
|
||||
function stubKakao(email?: string) {
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init: { headers: Record<string, string> }) => {
|
||||
const token = init.headers.Authorization?.replace('Bearer ', '');
|
||||
if (token === 'expired') return new Response('{}', { status: 401 });
|
||||
if (url.endsWith('/v1/user/access_token_info')) {
|
||||
return Response.json({ id: Number(KAKAO_ID), expires_in: 100, app_id: token === 'other-app' ? 1 : Number(APP_ID) });
|
||||
}
|
||||
return Response.json({
|
||||
id: Number(KAKAO_ID),
|
||||
kakao_account: {
|
||||
profile: { nickname: '카카오팬' },
|
||||
...(email ? { email, is_email_valid: true, is_email_verified: true } : {}),
|
||||
},
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
async function wipe() {
|
||||
const uids = [OLD_UID, GOOGLE_UID, kakaoUidOf(KAKAO_ID)];
|
||||
await getAuth().deleteUsers(uids);
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
if (!process.env.FIREBASE_AUTH_EMULATOR_HOST) {
|
||||
throw new Error('FIREBASE_AUTH_EMULATOR_HOST 가 없습니다. `npm run test:emulator` 로 실행하십시오.');
|
||||
}
|
||||
process.env.KAKAO_APP_ID = APP_ID;
|
||||
bootAdmin();
|
||||
});
|
||||
afterAll(async () => { await wipe(); await shutdownAdmin(); });
|
||||
beforeEach(async () => { await wipe(); stubKakao(); });
|
||||
afterEach(() => { vi.unstubAllGlobals(); });
|
||||
|
||||
describe('토큰 검증', () => {
|
||||
it('만료·무효 토큰과 남의 앱 토큰은 E-AUTH-03', async () => {
|
||||
expect(await codeOf(call({ accessToken: 'expired' }))).toBe('E-AUTH-03');
|
||||
expect(await codeOf(call({ accessToken: 'other-app' }))).toBe('E-AUTH-03');
|
||||
});
|
||||
});
|
||||
|
||||
describe('로그인', () => {
|
||||
it('OIDC 시절 oidc.kakao 가입자는 같은 UID 로 들어온다', async () => {
|
||||
await getAuth().createUser({ uid: OLD_UID });
|
||||
await getAuth().updateUser(OLD_UID, { providerToLink: { providerId: KAKAO_PROVIDER_ID, uid: KAKAO_ID } });
|
||||
const { token } = await call({ accessToken: 'good' }) as { token: string };
|
||||
expect(JSON.parse(Buffer.from(token.split('.')[1]!, 'base64url').toString()).uid).toBe(OLD_UID);
|
||||
});
|
||||
|
||||
it('신규는 kakao:<회원번호> 로 만들고 oidc.kakao 를 붙인다 · 두 번째도 같은 UID', async () => {
|
||||
const first = await call({ accessToken: 'good' }) as { token: string };
|
||||
await call({ accessToken: 'good' });
|
||||
const uid = JSON.parse(Buffer.from(first.token.split('.')[1]!, 'base64url').toString()).uid;
|
||||
expect(uid).toBe(kakaoUidOf(KAKAO_ID));
|
||||
const user = await getAuth().getUser(uid);
|
||||
expect(user.displayName).toBe('카카오팬');
|
||||
expect(user.providerData.map((d) => [d.providerId, d.uid])).toEqual([[KAKAO_PROVIDER_ID, KAKAO_ID]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SNS 연동', () => {
|
||||
it('로그인 없이 link 는 E-AUTH-03', async () => {
|
||||
expect(await codeOf(call({ accessToken: 'good', link: true }))).toBe('E-AUTH-03');
|
||||
});
|
||||
|
||||
it('Google 사용자에 붙인다 · 다른 UID 가 쓰는 카카오는 E-AUTH-08', async () => {
|
||||
await getAuth().createUser({ uid: GOOGLE_UID, email: 'fan@fanuzu.co.kr' });
|
||||
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);
|
||||
|
||||
await getAuth().createUser({ uid: OLD_UID });
|
||||
expect(await codeOf(call({ accessToken: 'good', link: true }, OLD_UID))).toBe('E-AUTH-08');
|
||||
});
|
||||
|
||||
it('이메일이 다르면 붙이지 않는다(different-email) · 카카오 이메일이 없으면 비교를 건너뛴다', 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 });
|
||||
});
|
||||
});
|
||||
@@ -6,7 +6,7 @@
|
||||
"scripts": {
|
||||
"test": "npm run test:rules && npm run test:functions",
|
||||
"test:rules": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix test test\"",
|
||||
"test:functions": "firebase emulators:exec --only firestore --project demo-fanuzu \"npm --prefix functions test\""
|
||||
"test:functions": "firebase emulators:exec --only firestore,auth --project demo-fanuzu \"npm --prefix functions test\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"firebase-tools": "15.28.2"
|
||||
|
||||
Reference in New Issue
Block a user