feat(gateway): verify Kakao account ownership
This commit is contained in:
@@ -53,6 +53,13 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
if (usersByName.has(input.username)) {
|
||||
throw new Error('User already exists.');
|
||||
}
|
||||
if (
|
||||
input.oauth &&
|
||||
(usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) ||
|
||||
usersByEmail.has(input.oauth.email.toLowerCase()))
|
||||
) {
|
||||
throw new Error('Kakao account already linked.');
|
||||
}
|
||||
for (const existing of usersByName.values()) {
|
||||
if ((input.displayName ?? input.username) === existing.displayName) {
|
||||
throw new Error('Display name already exists.');
|
||||
@@ -120,6 +127,39 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createSimp
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async syncKakaoIdentity(
|
||||
userId: string,
|
||||
email: string,
|
||||
oauthInfo: UserRecord['oauthInfo']
|
||||
): Promise<UserRecord> {
|
||||
const normalizedEmail = email.toLowerCase();
|
||||
const owner = usersByEmail.get(normalizedEmail);
|
||||
if (owner && owner.id !== userId) {
|
||||
throw new Error('Kakao email already linked.');
|
||||
}
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id !== userId) {
|
||||
continue;
|
||||
}
|
||||
if (user.email) {
|
||||
usersByEmail.delete(user.email.toLowerCase());
|
||||
}
|
||||
user.email = normalizedEmail;
|
||||
user.oauthInfo = oauthInfo;
|
||||
usersByEmail.set(normalizedEmail, user);
|
||||
return user;
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
|
||||
for (const user of usersByName.values()) {
|
||||
if (user.id === userId) {
|
||||
user.kakaoTalkVerifiedUntil = validUntil.toISOString();
|
||||
return user;
|
||||
}
|
||||
}
|
||||
throw new Error('User not found.');
|
||||
},
|
||||
async linkKakao(userId, input): Promise<UserRecord> {
|
||||
if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) {
|
||||
throw new Error('Kakao account already linked.');
|
||||
|
||||
@@ -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]),
|
||||
];
|
||||
@@ -23,11 +23,29 @@ export interface OAuthSession {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface KakaoLoginChallenge {
|
||||
id: string;
|
||||
userId: string;
|
||||
code: string;
|
||||
attemptsRemaining: number;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type KakaoLoginChallengeResult =
|
||||
| { status: 'verified'; userId: string }
|
||||
| { status: 'mismatch'; attemptsRemaining: number; expiresAt: string }
|
||||
| { status: 'locked'; expiresAt: string }
|
||||
| { status: 'expired' };
|
||||
|
||||
export interface OAuthSessionStore {
|
||||
createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState>;
|
||||
consumePendingState(state: string): Promise<OAuthPendingState | null>;
|
||||
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
|
||||
consumeSession(sessionId: string): Promise<OAuthSession | null>;
|
||||
getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null>;
|
||||
createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge>;
|
||||
verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise<KakaoLoginChallengeResult>;
|
||||
}
|
||||
|
||||
interface RedisPipeline {
|
||||
@@ -40,9 +58,39 @@ interface RedisClientLike {
|
||||
get(key: string): Promise<string | null>;
|
||||
set(key: string, value: string, options?: { EX?: number }): Promise<unknown>;
|
||||
del(key: string): Promise<number>;
|
||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
||||
multi(): RedisPipeline;
|
||||
}
|
||||
|
||||
const verifyLoginChallengeScript = `
|
||||
local raw = redis.call('GET', KEYS[1])
|
||||
if not raw then
|
||||
return '{"status":"expired"}'
|
||||
end
|
||||
|
||||
local challenge = cjson.decode(raw)
|
||||
if challenge.attemptsRemaining <= 0 then
|
||||
return cjson.encode({ status = 'locked', expiresAt = challenge.expiresAt })
|
||||
end
|
||||
|
||||
if tostring(challenge.code) ~= ARGV[1] then
|
||||
challenge.attemptsRemaining = challenge.attemptsRemaining - 1
|
||||
redis.call('SET', KEYS[1], cjson.encode(challenge), 'KEEPTTL')
|
||||
return cjson.encode({
|
||||
status = 'mismatch',
|
||||
attemptsRemaining = challenge.attemptsRemaining,
|
||||
expiresAt = challenge.expiresAt
|
||||
})
|
||||
end
|
||||
|
||||
redis.call('DEL', KEYS[1])
|
||||
local userKey = ARGV[2] .. challenge.userId
|
||||
if redis.call('GET', userKey) == challenge.id then
|
||||
redis.call('DEL', userKey)
|
||||
end
|
||||
return cjson.encode({ status = 'verified', userId = challenge.userId })
|
||||
`;
|
||||
|
||||
export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
private readonly client: RedisClientLike;
|
||||
private readonly prefix: string;
|
||||
@@ -62,6 +110,18 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
return `${this.prefix}:oauth-session:${sessionId}`;
|
||||
}
|
||||
|
||||
private loginChallengeKey(challengeId: string): string {
|
||||
return `${this.prefix}:kakao-login-challenge:${challengeId}`;
|
||||
}
|
||||
|
||||
private userLoginChallengeKey(userId: string): string {
|
||||
return `${this.prefix}:kakao-login-challenge-user:${userId}`;
|
||||
}
|
||||
|
||||
private challengeTtlSeconds(expiresAt: string): number {
|
||||
return Math.max(1, Math.ceil((new Date(expiresAt).getTime() - Date.now()) / 1000));
|
||||
}
|
||||
|
||||
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
|
||||
const state: OAuthPendingState = {
|
||||
state: randomUUID(),
|
||||
@@ -106,12 +166,63 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
|
||||
await this.client.del(key);
|
||||
return parseJson<OAuthSession>(raw);
|
||||
}
|
||||
|
||||
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
||||
const challengeId = await this.client.get(this.userLoginChallengeKey(userId));
|
||||
if (!challengeId) {
|
||||
return null;
|
||||
}
|
||||
const raw = await this.client.get(this.loginChallengeKey(challengeId));
|
||||
if (!raw) {
|
||||
await this.client.del(this.userLoginChallengeKey(userId));
|
||||
return null;
|
||||
}
|
||||
const challenge = parseJson<KakaoLoginChallenge>(raw);
|
||||
if (!challenge) {
|
||||
await this.client.del(this.userLoginChallengeKey(userId));
|
||||
return null;
|
||||
}
|
||||
if (new Date(challenge.expiresAt).getTime() <= Date.now()) {
|
||||
await this.client
|
||||
.multi()
|
||||
.del(this.loginChallengeKey(challenge.id))
|
||||
.del(this.userLoginChallengeKey(userId))
|
||||
.exec();
|
||||
return null;
|
||||
}
|
||||
return challenge;
|
||||
}
|
||||
|
||||
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
|
||||
const stored: KakaoLoginChallenge = {
|
||||
...challenge,
|
||||
id: randomUUID(),
|
||||
};
|
||||
const ttlSeconds = this.challengeTtlSeconds(stored.expiresAt);
|
||||
await this.client
|
||||
.multi()
|
||||
.set(this.loginChallengeKey(stored.id), JSON.stringify(stored), { EX: ttlSeconds })
|
||||
.set(this.userLoginChallengeKey(stored.userId), stored.id, { EX: ttlSeconds })
|
||||
.exec();
|
||||
return stored;
|
||||
}
|
||||
|
||||
async verifyLoginChallenge(challengeId: string, code: string): Promise<KakaoLoginChallengeResult> {
|
||||
const raw = await this.client.eval(verifyLoginChallengeScript, {
|
||||
keys: [this.loginChallengeKey(challengeId)],
|
||||
arguments: [code, `${this.prefix}:kakao-login-challenge-user:`],
|
||||
});
|
||||
const result = typeof raw === 'string' ? parseJson<KakaoLoginChallengeResult>(raw) : null;
|
||||
return result ?? { status: 'expired' };
|
||||
}
|
||||
}
|
||||
|
||||
// 테스트용 인메모리 OAuth 세션 저장소.
|
||||
export class InMemoryOAuthSessionStore implements OAuthSessionStore {
|
||||
private readonly pendingStates = new Map<string, OAuthPendingState>();
|
||||
private readonly sessions = new Map<string, OAuthSession>();
|
||||
private readonly loginChallenges = new Map<string, KakaoLoginChallenge>();
|
||||
private readonly userLoginChallenges = new Map<string, string>();
|
||||
|
||||
async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState> {
|
||||
const pending: OAuthPendingState = {
|
||||
@@ -149,4 +260,60 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore {
|
||||
}
|
||||
return session;
|
||||
}
|
||||
|
||||
async getLoginChallengeForUser(userId: string): Promise<KakaoLoginChallenge | null> {
|
||||
const challengeId = this.userLoginChallenges.get(userId);
|
||||
const challenge = challengeId ? this.loginChallenges.get(challengeId) : undefined;
|
||||
if (!challenge || new Date(challenge.expiresAt).getTime() <= Date.now()) {
|
||||
if (challengeId) {
|
||||
this.loginChallenges.delete(challengeId);
|
||||
}
|
||||
this.userLoginChallenges.delete(userId);
|
||||
return null;
|
||||
}
|
||||
return challenge;
|
||||
}
|
||||
|
||||
async createLoginChallenge(challenge: Omit<KakaoLoginChallenge, 'id'>): Promise<KakaoLoginChallenge> {
|
||||
const stored: KakaoLoginChallenge = {
|
||||
...challenge,
|
||||
id: randomUUID(),
|
||||
};
|
||||
this.loginChallenges.set(stored.id, stored);
|
||||
this.userLoginChallenges.set(stored.userId, stored.id);
|
||||
return stored;
|
||||
}
|
||||
|
||||
async verifyLoginChallenge(
|
||||
challengeId: string,
|
||||
code: string,
|
||||
now = new Date()
|
||||
): Promise<KakaoLoginChallengeResult> {
|
||||
const challenge = this.loginChallenges.get(challengeId);
|
||||
if (!challenge || new Date(challenge.expiresAt).getTime() <= now.getTime()) {
|
||||
if (challenge) {
|
||||
this.loginChallenges.delete(challengeId);
|
||||
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
|
||||
this.userLoginChallenges.delete(challenge.userId);
|
||||
}
|
||||
}
|
||||
return { status: 'expired' };
|
||||
}
|
||||
if (challenge.attemptsRemaining <= 0) {
|
||||
return { status: 'locked', expiresAt: challenge.expiresAt };
|
||||
}
|
||||
if (challenge.code !== code) {
|
||||
challenge.attemptsRemaining -= 1;
|
||||
return {
|
||||
status: 'mismatch',
|
||||
attemptsRemaining: challenge.attemptsRemaining,
|
||||
expiresAt: challenge.expiresAt,
|
||||
};
|
||||
}
|
||||
this.loginChallenges.delete(challengeId);
|
||||
if (this.userLoginChallenges.get(challenge.userId) === challengeId) {
|
||||
this.userLoginChallenges.delete(challenge.userId);
|
||||
}
|
||||
return { status: 'verified', userId: challenge.userId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ const mapUser = (row: {
|
||||
termsAcceptedAt: Date | null;
|
||||
privacyAcceptedAt: Date | null;
|
||||
kakaoVerifiedAt: Date | null;
|
||||
kakaoTalkVerifiedUntil: Date | null;
|
||||
kakaoGraceStartedAt: Date;
|
||||
kakaoGraceUntil: Date | null;
|
||||
deleteAfter: Date | null;
|
||||
@@ -83,6 +84,7 @@ const mapUser = (row: {
|
||||
termsAcceptedAt: row.termsAcceptedAt?.toISOString(),
|
||||
privacyAcceptedAt: row.privacyAcceptedAt?.toISOString(),
|
||||
kakaoVerifiedAt: row.kakaoVerifiedAt?.toISOString(),
|
||||
kakaoTalkVerifiedUntil: row.kakaoTalkVerifiedUntil?.toISOString(),
|
||||
kakaoGraceStartedAt: row.kakaoGraceStartedAt.toISOString(),
|
||||
kakaoGraceUntil: row.kakaoGraceUntil?.toISOString(),
|
||||
deleteAfter: row.deleteAfter?.toISOString(),
|
||||
@@ -223,6 +225,23 @@ export const createPostgresUserRepository = (
|
||||
},
|
||||
});
|
||||
},
|
||||
async syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord> {
|
||||
const row = await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
email: email.toLowerCase(),
|
||||
oauthInfo: oauthInfo as GatewayPrisma.JsonObject,
|
||||
},
|
||||
});
|
||||
return mapUser(row);
|
||||
},
|
||||
async markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord> {
|
||||
const row = await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
data: { kakaoTalkVerifiedUntil: validUntil },
|
||||
});
|
||||
return mapUser(row);
|
||||
},
|
||||
async linkKakao(userId, input): Promise<UserRecord> {
|
||||
const row = await prisma.appUser.update({
|
||||
where: { id: userId },
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface UserRecord {
|
||||
termsAcceptedAt?: string;
|
||||
privacyAcceptedAt?: string;
|
||||
kakaoVerifiedAt?: string;
|
||||
kakaoTalkVerifiedUntil?: string;
|
||||
kakaoGraceStartedAt: string;
|
||||
kakaoGraceUntil?: string;
|
||||
deleteAfter?: string;
|
||||
@@ -110,6 +111,8 @@ export interface UserRepository {
|
||||
verifyPassword(user: UserRecord, password: string): Promise<boolean>;
|
||||
updatePassword(userId: string, password: string): Promise<void>;
|
||||
updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise<void>;
|
||||
syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise<UserRecord>;
|
||||
markKakaoTalkVerified(userId: string, validUntil: Date): Promise<UserRecord>;
|
||||
linkKakao(
|
||||
userId: string,
|
||||
input: {
|
||||
|
||||
Reference in New Issue
Block a user