280 lines
10 KiB
TypeScript
280 lines
10 KiB
TypeScript
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';
|
|
|
|
const KAKAO_LOGIN_SCOPES = ['account_email', 'talk_message'] as const;
|
|
const KAKAO_OTP_TTL_SECONDS = 180;
|
|
const KAKAO_OTP_ATTEMPTS = 3;
|
|
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]),
|
|
];
|