LINE 로그인을 LINE SDK와 서버 인증 토큰 방식으로 변경 — iOS·Android 웹 로그인 중단 문제 해결
This commit is contained in:
@@ -22,7 +22,7 @@ export const fnAuthOnCreate = functionsV1.auth.user().onCreate(async (user) => {
|
||||
const walletRef = db.doc(`users/${user.uid}/wallet/main`);
|
||||
// 이메일 — 플랫폼이 이메일 제공을 승인하고 사용자가 동의한 경우에만 수집한다. 없어도 가입·로그인된다.
|
||||
// Auth 계정 이메일이 비면(OIDC 등) 제공자 정보의 이메일을 쓴다. 카카오(커스텀 토큰)는
|
||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `fnAuthKakao` 가 따로 쓴다.
|
||||
// 계정을 만든 뒤에 연결 정보가 붙으므로 `exchangeSocial`(social_token.ts)이 따로 쓴다.
|
||||
const email = user.email ?? user.providerData.find((p) => p.email)?.email ?? null;
|
||||
const defaults: Record<string, unknown> = {
|
||||
uid: user.uid,
|
||||
|
||||
@@ -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 연동 규칙은 LINE 과 같아 `social_token.ts` 에 있다.
|
||||
*
|
||||
* 입력: `{ accessToken, link? }`. `link: true` 면 로그인한 호출자 UID 에 카카오를 붙이고
|
||||
* `{ linked: true }`, 아니면 `{ token }`(커스텀 토큰)을 돌려준다.
|
||||
@@ -74,92 +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;
|
||||
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] 에 카카오를 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
||||
*
|
||||
* 현재 FANUZU 계정의 유효한 인증 상태와 새 Provider 인증 자격을 **서버에서 모두** 확인해 붙인다.
|
||||
* 이메일 일치는 소유 확인이 아니다 — 비교하지 않는다(이메일을 주지 않는 카카오 계정도 연결된다).
|
||||
* - 새 Provider 자격: [verifyKakaoToken] 이 우리 앱에서 발급된 유효 토큰인지 카카오 API 로 본다.
|
||||
* - 현재 계정: 호출 토큰(callable 이 서명 · 만료를 검증)에 더해, 계정이 정지 상태가 아니고 토큰이
|
||||
* 세션 폐기(`tokensValidAfterTime`) 이후에 발급됐는지 본다 — 「세션 만료 · 인증 취소」.
|
||||
* - 그 카카오 회원번호가 다른 UID 에 붙어 있으면 거부(E-AUTH-08). 붙이는 순간 다른 UID 가 먼저 붙였어도
|
||||
* (동시 연결 충돌) 붙인 뒤 소유자를 다시 읽어 확인하고, 다르면 거부한다.
|
||||
*/
|
||||
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, '이미 다른 카카오 계정이 연결되어 있습니다.');
|
||||
}
|
||||
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, '이미 다른 계정에 연결된 카카오 계정입니다.');
|
||||
}
|
||||
}
|
||||
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, '로그인이 필요합니다.');
|
||||
const authTime = req.auth?.token?.auth_time;
|
||||
await linkKakao(uid, profile, typeof authTime === 'number' ? authTime : undefined);
|
||||
return { linked: true };
|
||||
}
|
||||
const uid = await resolveKakaoUid(profile);
|
||||
// 동의한 이메일만 사용자 문서에 남긴다. 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」로 끝난다(`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,127 @@
|
||||
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;
|
||||
/** 동의했고 제공자가 준 이메일만. 없어도 가입 · 로그인 · 연결이 된다. */
|
||||
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;
|
||||
const uid = `${spec.uidPrefix}:${p.id}`;
|
||||
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] 에 제공자 계정을 붙인다. 새 계정을 만들지 않고 자동 병합하지 않는다.
|
||||
*
|
||||
* 현재 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;
|
||||
if (user.disabled || (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);
|
||||
// 동의한 이메일만 사용자 문서에 남긴다. Auth 이메일은 다른 제공자 계정과 겹치면 생성이
|
||||
// 거부되므로 쓰지 않는다. 이메일이 없어도 로그인은 그대로 된다.
|
||||
if (profile.email) {
|
||||
await getFirestore().doc(`users/${uid}`).set({ email: profile.email }, { merge: true });
|
||||
}
|
||||
return { token: await getAuth().createCustomToken(uid) };
|
||||
}
|
||||
@@ -7,4 +7,5 @@ export {
|
||||
fnAuthOnCreate, fnConsentRecord, fnAdminSetRole,
|
||||
} from './account/account.js';
|
||||
export { fnAuthKakao } from './account/kakao.js';
|
||||
export { fnAuthLine } from './account/line.js';
|
||||
export { fnAdminWrite } from './admin/admin.js';
|
||||
|
||||
Reference in New Issue
Block a user