feat(gateway): verify Kakao account ownership
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
import { randomInt } from 'node:crypto';
|
||||
|
||||
import { addDays, addSeconds, isAfter, isValid, parseISO } from 'date-fns';
|
||||
|
||||
import type { KakaoOAuthClient, KakaoOAuthToken, KakaoUserInfo } from './kakaoClient.js';
|
||||
import type { OAuthSessionStore } from './oauthSessionStore.js';
|
||||
import type { UserOAuthInfo, UserRecord, UserRepository } from './userRepository.js';
|
||||
|
||||
export const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
|
||||
export const KAKAO_OTP_TTL_SECONDS = 180;
|
||||
export const KAKAO_OTP_ATTEMPTS = 3;
|
||||
export const KAKAO_TALK_VERIFICATION_DAYS = 10;
|
||||
|
||||
export type KakaoVerificationErrorCode =
|
||||
| 'EMAIL_REQUIRED'
|
||||
| 'EMAIL_UNVERIFIED'
|
||||
| 'EMAIL_CONFLICT'
|
||||
| 'IDENTITY_MISMATCH'
|
||||
| 'REAUTH_REQUIRED'
|
||||
| 'MESSAGE_FAILED';
|
||||
|
||||
export class KakaoVerificationError extends Error {
|
||||
readonly verificationCode: KakaoVerificationErrorCode;
|
||||
|
||||
constructor(verificationCode: KakaoVerificationErrorCode, message: string, options?: ErrorOptions) {
|
||||
super(message, options);
|
||||
this.name = 'KakaoVerificationError';
|
||||
this.verificationCode = verificationCode;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VerifiedKakaoProfile {
|
||||
kakaoId: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface KakaoLoginReady {
|
||||
user: UserRecord;
|
||||
accessToken: string;
|
||||
oauthInfo: UserOAuthInfo;
|
||||
}
|
||||
|
||||
export interface KakaoOtpRequired {
|
||||
status: 'otp';
|
||||
challengeId: string;
|
||||
expiresAt: string;
|
||||
attemptsRemaining: number;
|
||||
}
|
||||
|
||||
const parseDate = (value: string | undefined): Date | null => {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const normalized = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d+)?$/.test(value)
|
||||
? `${value.replace(' ', 'T')}Z`
|
||||
: value;
|
||||
const parsed = parseISO(normalized);
|
||||
return isValid(parsed) ? parsed : null;
|
||||
};
|
||||
|
||||
export const readVerifiedKakaoProfile = (me: KakaoUserInfo): VerifiedKakaoProfile => {
|
||||
const account = me.kakaoAccount;
|
||||
if (!account.hasEmail || !account.email) {
|
||||
throw new KakaoVerificationError('EMAIL_REQUIRED', '이메일 정보 제공에 동의해야 합니다.');
|
||||
}
|
||||
if (!account.isEmailValid || !account.isEmailVerified) {
|
||||
throw new KakaoVerificationError('EMAIL_UNVERIFIED', '카카오 계정 이메일이 인증되지 않았습니다.');
|
||||
}
|
||||
if (!me.id) {
|
||||
throw new KakaoVerificationError('IDENTITY_MISMATCH', '카카오 계정 고유 ID를 확인하지 못했습니다.');
|
||||
}
|
||||
return {
|
||||
kakaoId: me.id,
|
||||
email: account.email.trim().toLocaleLowerCase('en-US'),
|
||||
};
|
||||
};
|
||||
|
||||
export const oauthInfoFromToken = (
|
||||
token: KakaoOAuthToken,
|
||||
issuedAt: Date,
|
||||
previous: UserOAuthInfo = {}
|
||||
): UserOAuthInfo => ({
|
||||
...previous,
|
||||
accessToken: token.accessToken,
|
||||
refreshToken: token.refreshToken ?? previous.refreshToken,
|
||||
accessTokenValidUntil: addSeconds(issuedAt, token.accessTokenExpiresIn).toISOString(),
|
||||
refreshTokenValidUntil: token.refreshTokenExpiresIn
|
||||
? addSeconds(issuedAt, token.refreshTokenExpiresIn).toISOString()
|
||||
: previous.refreshTokenValidUntil,
|
||||
});
|
||||
|
||||
const resolveStoredAccessToken = async (
|
||||
user: UserRecord,
|
||||
kakaoClient: KakaoOAuthClient,
|
||||
now: Date
|
||||
): Promise<{ accessToken: string; oauthInfo: UserOAuthInfo }> => {
|
||||
const oauthInfo = user.oauthInfo ?? {};
|
||||
const accessValidUntil = parseDate(oauthInfo.accessTokenValidUntil);
|
||||
if (oauthInfo.accessToken && accessValidUntil && isAfter(accessValidUntil, now)) {
|
||||
return { accessToken: oauthInfo.accessToken, oauthInfo };
|
||||
}
|
||||
|
||||
const refreshValidUntil = parseDate(oauthInfo.refreshTokenValidUntil);
|
||||
if (!oauthInfo.refreshToken || !refreshValidUntil || !isAfter(refreshValidUntil, now)) {
|
||||
throw new KakaoVerificationError(
|
||||
'REAUTH_REQUIRED',
|
||||
'카카오 로그인 토큰이 만료되었습니다. 카카오 로그인을 다시 수행해 주세요.'
|
||||
);
|
||||
}
|
||||
|
||||
let refreshed: KakaoOAuthToken;
|
||||
try {
|
||||
refreshed = await kakaoClient.refreshToken(oauthInfo.refreshToken);
|
||||
} catch (error) {
|
||||
throw new KakaoVerificationError(
|
||||
'REAUTH_REQUIRED',
|
||||
'카카오 로그인 토큰을 갱신하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.',
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
if (!refreshed.accessToken || refreshed.accessTokenExpiresIn <= 0) {
|
||||
throw new KakaoVerificationError(
|
||||
'REAUTH_REQUIRED',
|
||||
'카카오 로그인 토큰을 갱신하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.'
|
||||
);
|
||||
}
|
||||
return {
|
||||
accessToken: refreshed.accessToken,
|
||||
oauthInfo: oauthInfoFromToken(refreshed, now, oauthInfo),
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyStoredKakaoIdentity = async (options: {
|
||||
user: UserRecord;
|
||||
users: UserRepository;
|
||||
kakaoClient: KakaoOAuthClient;
|
||||
now?: Date;
|
||||
}): Promise<KakaoLoginReady> => {
|
||||
const { user, users, kakaoClient } = options;
|
||||
const now = options.now ?? new Date();
|
||||
if (user.oauthType !== 'KAKAO' || !user.oauthId) {
|
||||
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보가 올바르지 않습니다.');
|
||||
}
|
||||
|
||||
const resolved = await resolveStoredAccessToken(user, kakaoClient, now);
|
||||
let profile: VerifiedKakaoProfile;
|
||||
try {
|
||||
profile = readVerifiedKakaoProfile(await kakaoClient.getMe(resolved.accessToken));
|
||||
} catch (error) {
|
||||
if (error instanceof KakaoVerificationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new KakaoVerificationError(
|
||||
'REAUTH_REQUIRED',
|
||||
'카카오 계정 정보를 확인하지 못했습니다. 카카오 로그인을 다시 수행해 주세요.',
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
if (profile.kakaoId !== user.oauthId) {
|
||||
throw new KakaoVerificationError(
|
||||
'IDENTITY_MISMATCH',
|
||||
'저장된 카카오 계정과 현재 카카오 계정이 일치하지 않습니다.'
|
||||
);
|
||||
}
|
||||
const emailOwner = await users.findByEmail(profile.email);
|
||||
if (emailOwner && emailOwner.id !== user.id) {
|
||||
throw new KakaoVerificationError(
|
||||
'EMAIL_CONFLICT',
|
||||
'변경된 카카오 이메일이 이미 다른 계정에서 사용 중입니다. 관리자에게 문의해 주세요.'
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const synced = await users.syncKakaoIdentity(user.id, profile.email, resolved.oauthInfo);
|
||||
return {
|
||||
user: synced,
|
||||
accessToken: resolved.accessToken,
|
||||
oauthInfo: resolved.oauthInfo,
|
||||
};
|
||||
} catch (error) {
|
||||
throw new KakaoVerificationError(
|
||||
'EMAIL_CONFLICT',
|
||||
'변경된 카카오 이메일이 이미 다른 계정에서 사용 중입니다. 관리자에게 문의해 주세요.',
|
||||
{ cause: error }
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export const requireKakaoTalkProof = async (options: {
|
||||
user: UserRecord;
|
||||
accessToken: string;
|
||||
kakaoClient: KakaoOAuthClient;
|
||||
oauthSessions: OAuthSessionStore;
|
||||
publicBaseUrl: string;
|
||||
now?: Date;
|
||||
}): Promise<KakaoOtpRequired | null> => {
|
||||
const now = options.now ?? new Date();
|
||||
const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil);
|
||||
if (validUntil && isAfter(validUntil, now)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id);
|
||||
if (active) {
|
||||
return {
|
||||
status: 'otp',
|
||||
challengeId: active.id,
|
||||
expiresAt: active.expiresAt,
|
||||
attemptsRemaining: active.attemptsRemaining,
|
||||
};
|
||||
}
|
||||
|
||||
const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS);
|
||||
const challenge = await options.oauthSessions.createLoginChallenge({
|
||||
userId: options.user.id,
|
||||
code: String(randomInt(1000, 10_000)),
|
||||
attemptsRemaining: KAKAO_OTP_ATTEMPTS,
|
||||
expiresAt: expiresAt.toISOString(),
|
||||
createdAt: now.toISOString(),
|
||||
});
|
||||
try {
|
||||
await options.kakaoClient.sendTalkMessage(
|
||||
options.accessToken,
|
||||
`인증 코드는 ${challenge.code} 입니다. ${challenge.expiresAt} 이내에 입력해 주세요.`,
|
||||
options.publicBaseUrl
|
||||
);
|
||||
} catch (error) {
|
||||
await options.oauthSessions.verifyLoginChallenge(challenge.id, challenge.code);
|
||||
throw new KakaoVerificationError('MESSAGE_FAILED', '카카오톡 인증 코드를 보내지 못했습니다.', {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
return {
|
||||
status: 'otp',
|
||||
challengeId: challenge.id,
|
||||
expiresAt: challenge.expiresAt,
|
||||
attemptsRemaining: challenge.attemptsRemaining,
|
||||
};
|
||||
};
|
||||
|
||||
export const verifyKakaoTalkChallenge = async (options: {
|
||||
challengeId: string;
|
||||
code: string;
|
||||
oauthSessions: OAuthSessionStore;
|
||||
users: UserRepository;
|
||||
now?: Date;
|
||||
}): Promise<{ user: UserRecord; validUntil: string }> => {
|
||||
const now = options.now ?? new Date();
|
||||
const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now);
|
||||
if (result.status === 'expired') {
|
||||
throw new KakaoVerificationError('REAUTH_REQUIRED', '인증 기한이 만료되었습니다. 다시 로그인해 주세요.');
|
||||
}
|
||||
if (result.status === 'locked') {
|
||||
throw new KakaoVerificationError(
|
||||
'IDENTITY_MISMATCH',
|
||||
`인증 실패 횟수를 초과했습니다. ${result.expiresAt}까지 기다려 주세요.`
|
||||
);
|
||||
}
|
||||
if (result.status === 'mismatch') {
|
||||
throw new KakaoVerificationError(
|
||||
'IDENTITY_MISMATCH',
|
||||
result.attemptsRemaining > 0
|
||||
? `인증 번호가 틀렸습니다. ${result.attemptsRemaining}회 더 시도할 수 있습니다.`
|
||||
: '인증 실패 횟수를 초과했습니다. 다시 로그인해 주세요.'
|
||||
);
|
||||
}
|
||||
|
||||
const user = await options.users.findById(result.userId);
|
||||
if (!user || user.oauthType !== 'KAKAO' || !user.oauthId) {
|
||||
throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보를 찾지 못했습니다.');
|
||||
}
|
||||
const proofValidUntil = addDays(now, KAKAO_TALK_VERIFICATION_DAYS);
|
||||
const verified = await options.users.markKakaoTalkVerified(user.id, proofValidUntil);
|
||||
return { user: verified, validUntil: proofValidUntil.toISOString() };
|
||||
};
|
||||
|
||||
export const mergeRequiredKakaoScopes = (requested?: string[]): string[] => [
|
||||
...new Set([...(requested ?? []), ...KAKAO_LOGIN_SCOPES]),
|
||||
];
|
||||
Reference in New Issue
Block a user