diff --git a/README.md b/README.md index ffa15535..fa70d4e4 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,13 @@ orchestrator가 commit별 worktree와 PM2 process를 조정합니다. Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가 별도 `GatewayReleaseOperation` queue를 처리합니다. +Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와 +현재 인증 이메일을 다시 확인합니다. 새 Kakao ID의 이메일이 기존 계정에 있거나 +기존 Kakao ID의 변경 이메일이 다른 계정에 있으면 로그인을 거부합니다. 확인된 +이메일은 `AppUser.email`에 동기화하며, 10일마다 `talk_message` scope로 +“나와의 채팅” 숫자 코드를 보내 소유 증명을 완료한 뒤에만 새 Gateway session을 +발급합니다. + 각 game profile은 별도 PostgreSQL schema를 사용합니다. `game-api`는 인증된 요청을 검증하고 직접 처리할 mutation 또는 daemon 입력을 `InputEvent`에 기록합니다. `game-engine`은 DB lease와 fencing token을 확보한 diff --git a/app/game-frontend/e2e/commandArgumentsLive.spec.ts b/app/game-frontend/e2e/commandArgumentsLive.spec.ts index 39230749..27425cfb 100644 --- a/app/game-frontend/e2e/commandArgumentsLive.spec.ts +++ b/app/game-frontend/e2e/commandArgumentsLive.spec.ts @@ -31,6 +31,9 @@ test('reserves an argument command in the real game API and reads it back from P ).toString('base64'), }, }); + if (login.status !== 'login') { + throw new Error('Local live fixture unexpectedly requires Kakao OTP.'); + } const issued = await gateway.auth.issueGameSession.mutate({ sessionToken: login.sessionToken, profile: 'che:2', @@ -95,19 +98,15 @@ test('reserves an argument command in the real game API and reads it back from P await form.getByRole('button', { name: '쌀', exact: true }).click(); await form.locator('input[type=number]').fill('1'); const generalSelect = form.locator('select'); - const generalValues = await generalSelect - .locator('option') - .evaluateAll((options) => - options.map((option) => ({ - value: (option as HTMLOptionElement).value, - label: option.textContent ?? '', - })) - ); + const generalValues = await generalSelect.locator('option').evaluateAll((options) => + options.map((option) => ({ + value: (option as HTMLOptionElement).value, + label: option.textContent ?? '', + })) + ); const targetGeneralId = Number( generalValues.find( - (option) => - Number(option.value) !== generalId && - option.label.includes(`(${nationName} ·`) + (option) => Number(option.value) !== generalId && option.label.includes(`(${nationName} ·`) )?.value ); expect(targetGeneralId).toBeGreaterThan(0); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index c02a3591..1903af41 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -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 { + 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 { + 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 { if (usersByOauthId.has(`KAKAO:${input.oauthId}`) || usersByEmail.has(input.email.toLowerCase())) { throw new Error('Kakao account already linked.'); diff --git a/app/gateway-api/src/auth/kakaoAccountVerification.ts b/app/gateway-api/src/auth/kakaoAccountVerification.ts new file mode 100644 index 00000000..ba3de5fc --- /dev/null +++ b/app/gateway-api/src/auth/kakaoAccountVerification.ts @@ -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 => { + 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 => { + 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]), +]; diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 3e22e3ad..c1f19069 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -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; consumePendingState(state: string): Promise; createSession(session: Omit): Promise; consumeSession(sessionId: string): Promise; + getLoginChallengeForUser(userId: string): Promise; + createLoginChallenge(challenge: Omit): Promise; + verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise; } interface RedisPipeline { @@ -40,9 +58,39 @@ interface RedisClientLike { get(key: string): Promise; set(key: string, value: string, options?: { EX?: number }): Promise; del(key: string): Promise; + eval(script: string, options: { keys: string[]; arguments: string[] }): Promise; 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 { const state: OAuthPendingState = { state: randomUUID(), @@ -106,12 +166,63 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { await this.client.del(key); return parseJson(raw); } + + async getLoginChallengeForUser(userId: string): Promise { + 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(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): Promise { + 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 { + 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(raw) : null; + return result ?? { status: 'expired' }; + } } // 테스트용 인메모리 OAuth 세션 저장소. export class InMemoryOAuthSessionStore implements OAuthSessionStore { private readonly pendingStates = new Map(); private readonly sessions = new Map(); + private readonly loginChallenges = new Map(); + private readonly userLoginChallenges = new Map(); async createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise { const pending: OAuthPendingState = { @@ -149,4 +260,60 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore { } return session; } + + async getLoginChallengeForUser(userId: string): Promise { + 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): Promise { + 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 { + 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 }; + } } diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index 1dbbdc69..a0e7e2fd 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -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 { + 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 { + const row = await prisma.appUser.update({ + where: { id: userId }, + data: { kakaoTalkVerifiedUntil: validUntil }, + }); + return mapUser(row); + }, async linkKakao(userId, input): Promise { const row = await prisma.appUser.update({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 526e7951..73e01c9d 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -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; updatePassword(userId: string, password: string): Promise; updateOAuthInfo(userId: string, oauthInfo: UserOAuthInfo): Promise; + syncKakaoIdentity(userId: string, email: string, oauthInfo: UserOAuthInfo): Promise; + markKakaoTalkVerified(userId: string, validUntil: Date): Promise; linkKakao( userId: string, input: { diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index ffd91d4c..c8712e10 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -9,13 +9,23 @@ import { isGameAccessBlocked, isLoginBanned } from '@sammo-ts/common/auth/sancti import { procedure, router } from './trpc.js'; import { toPublicUser } from './auth/userRepository.js'; -import type { UserOAuthInfo } from './auth/userRepository.js'; +import type { UserOAuthInfo, UserRecord } from './auth/userRepository.js'; import { adminRouter } from './adminRouter.js'; import { accountRouter } from './account/router.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js'; import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; +import type { GatewayApiContext } from './context.js'; +import { + KakaoVerificationError, + mergeRequiredKakaoScopes, + oauthInfoFromToken, + readVerifiedKakaoProfile, + requireKakaoTalkProof, + verifyKakaoTalkChallenge, + verifyStoredKakaoIdentity, +} from './auth/kakaoAccountVerification.js'; const zUsername = z .string() @@ -32,6 +42,50 @@ const parseDate = (value: string): Date | null => { return isValid(parsed) ? parsed : null; }; +const throwKakaoVerificationError = (error: unknown): never => { + if (!(error instanceof KakaoVerificationError)) { + throw error; + } + const code = + error.verificationCode === 'EMAIL_CONFLICT' + ? 'CONFLICT' + : error.verificationCode === 'IDENTITY_MISMATCH' + ? 'UNAUTHORIZED' + : error.verificationCode === 'EMAIL_REQUIRED' || error.verificationCode === 'EMAIL_UNVERIFIED' + ? 'BAD_REQUEST' + : 'PRECONDITION_FAILED'; + throw new TRPCError({ code, message: error.message, cause: error }); +}; + +const finishKakaoLogin = async ( + ctx: GatewayApiContext, + user: UserRecord, + accessToken: string, + successStatus: T +) => { + try { + const challenge = await requireKakaoTalkProof({ + user, + accessToken, + kakaoClient: ctx.kakaoClient, + oauthSessions: ctx.oauthSessions, + publicBaseUrl: ctx.publicBaseUrl, + }); + if (challenge) { + return { ...challenge, successStatus }; + } + } catch (error) { + throwKakaoVerificationError(error); + } + const session = await ctx.sessions.createSession(user); + return { + status: successStatus, + user: toPublicUser(user), + sessionToken: session.sessionToken, + issuedAt: session.issuedAt, + }; +}; + export const appRouter = router({ health: router({ ping: procedure.query(() => ({ @@ -157,7 +211,7 @@ export const appRouter = router({ ) .query(async ({ ctx, input }) => { const mode = input?.mode ?? 'login'; - const scopes = input?.scopes ?? ['account_email']; + const scopes = mergeRequiredKakaoScopes(input?.scopes); let userId: string | undefined; if (mode === 'verify') { const sessionToken = @@ -196,10 +250,6 @@ export const appRouter = router({ } const token = await ctx.kakaoClient.exchangeCode(input.code); const tokenIssuedAt = new Date(); - const accessTokenValidUntil = addSeconds(tokenIssuedAt, token.accessTokenExpiresIn).toISOString(); - const refreshTokenValidUntil = token.refreshTokenExpiresIn - ? addSeconds(tokenIssuedAt, token.refreshTokenExpiresIn).toISOString() - : undefined; const signupResult = await ctx.kakaoClient.signup(token.accessToken); if (!signupResult.id && signupResult.msg !== 'already registered') { @@ -209,30 +259,23 @@ export const appRouter = router({ }); } const me = await ctx.kakaoClient.getMe(token.accessToken); - const kakaoAccount = me.kakaoAccount; - if (!kakaoAccount.hasEmail || !kakaoAccount.email) { + const profile = (() => { + try { + return readVerifiedKakaoProfile(me); + } catch (error) { + return throwKakaoVerificationError(error); + } + })(); + const [existingById, existingByEmail] = await Promise.all([ + ctx.users.findByOauthId('KAKAO', profile.kakaoId), + ctx.users.findByEmail(profile.email), + ]); + if (existingByEmail && existingByEmail.id !== existingById?.id) { throw new TRPCError({ - code: 'BAD_REQUEST', - message: '이메일 정보 제공에 동의해야 합니다.', + code: 'CONFLICT', + message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', }); } - if (!kakaoAccount.isEmailValid || !kakaoAccount.isEmailVerified) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: '카카오 계정 이메일이 인증되지 않았습니다.', - }); - } - - const oauthInfo: UserOAuthInfo = { - accessToken: token.accessToken, - refreshToken: token.refreshToken, - accessTokenValidUntil, - refreshTokenValidUntil, - }; - - const existing = - (await ctx.users.findByOauthId('KAKAO', me.id)) ?? - (await ctx.users.findByEmail(kakaoAccount.email)); if (pending.mode === 'verify') { if (!pending.userId) { @@ -254,18 +297,31 @@ export const appRouter = router({ message: 'Account login is blocked.', }); } - if (existing && existing.id !== localUser.id) { + if (existingById && existingById.id !== localUser.id) { throw new TRPCError({ code: 'CONFLICT', message: '이미 다른 계정에 연결된 카카오 계정입니다.', }); } - let verified = localUser; - if (existing?.id !== localUser.id) { + if (existingByEmail && existingByEmail.id !== localUser.id) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', + }); + } + if (localUser.oauthType === 'KAKAO' && localUser.oauthId !== profile.kakaoId) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 다른 카카오 계정에 연결된 사용자입니다.', + }); + } + const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo); + let verified: UserRecord; + if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') { try { verified = await ctx.users.linkKakao(localUser.id, { - oauthId: me.id, - email: kakaoAccount.email, + oauthId: profile.kakaoId, + email: profile.email, oauthInfo, verifiedAt: new Date(), }); @@ -276,28 +332,40 @@ export const appRouter = router({ cause: error, }); } - } - if (existing?.id === localUser.id) { - await ctx.users.updateOAuthInfo(localUser.id, oauthInfo); + } else { + try { + verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo); + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', + cause: error, + }); + } } const refreshed = (await ctx.users.findById(verified.id)) ?? verified; - const session = await ctx.sessions.createSession(refreshed); await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified'); - return { - status: 'verified' as const, - user: toPublicUser(refreshed), - sessionToken: session.sessionToken, - issuedAt: session.issuedAt, - }; + return finishKakaoLogin(ctx, refreshed, token.accessToken, 'verified'); } if (pending.mode === 'change_pw') { - if (!existing) { + if (!existingById) { throw new TRPCError({ code: 'NOT_FOUND', message: '카카오 계정에 연결된 사용자를 찾지 못했습니다.', }); } + const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, existingById.oauthInfo); + let existing: UserRecord; + try { + existing = await ctx.users.syncKakaoIdentity(existingById.id, profile.email, oauthInfo); + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', + cause: error, + }); + } const nextPasswordChange = existing.oauthInfo?.nextPasswordChange ? parseDate(existing.oauthInfo.nextPasswordChange) : null; @@ -326,31 +394,36 @@ export const appRouter = router({ }; } - if (existing) { - if (isLoginBanned(existing.sanctions)) { + if (existingById) { + if (isLoginBanned(existingById.sanctions)) { throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.', }); } - await ctx.users.updateOAuthInfo(existing.id, oauthInfo); - const session = await ctx.sessions.createSession(existing); - return { - status: 'login' as const, - user: toPublicUser(existing), - sessionToken: session.sessionToken, - issuedAt: session.issuedAt, - }; + const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, existingById.oauthInfo); + let synced: UserRecord; + try { + synced = await ctx.users.syncKakaoIdentity(existingById.id, profile.email, oauthInfo); + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', + cause: error, + }); + } + return finishKakaoLogin(ctx, synced, token.accessToken, 'login'); } + const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); const stored = await ctx.oauthSessions.createSession({ mode: pending.mode, - kakaoId: me.id, - email: kakaoAccount.email, + kakaoId: profile.kakaoId, + email: profile.email, accessToken: token.accessToken, refreshToken: token.refreshToken, - accessTokenValidUntil, - refreshTokenValidUntil, + accessTokenValidUntil: joinOauthInfo.accessTokenValidUntil!, + refreshTokenValidUntil: joinOauthInfo.refreshTokenValidUntil, createdAt: new Date().toISOString(), }); @@ -440,12 +513,7 @@ export const appRouter = router({ cause: error, }); } - const session = await ctx.sessions.createSession(created); - return { - user: toPublicUser(created), - sessionToken: session.sessionToken, - issuedAt: session.issuedAt, - }; + return finishKakaoLogin(ctx, created, oauthSession.accessToken, 'login'); }), passwordKey: procedure.query(({ ctx }) => ctx.passwordEnvelope.getPublicKey()), checkRegistrationField: procedure @@ -566,13 +634,57 @@ export const appRouter = router({ message: 'Account login is blocked.', }); } + if (user.oauthType === 'KAKAO') { + const ready = await verifyStoredKakaoIdentity({ + user, + users: ctx.users, + kakaoClient: ctx.kakaoClient, + }).catch((error: unknown) => throwKakaoVerificationError(error)); + return finishKakaoLogin(ctx, ready.user, ready.accessToken, 'login'); + } const session = await ctx.sessions.createSession(user); return { + status: 'login' as const, user: toPublicUser(user), sessionToken: session.sessionToken, issuedAt: session.issuedAt, }; }), + kakaoOtp: procedure + .input( + z.object({ + challengeId: z.string().uuid(), + code: z.string().regex(/^\d{4}$/, '인증 코드는 숫자 4자리입니다.'), + }) + ) + .mutation(async ({ ctx, input }) => { + const verified = await verifyKakaoTalkChallenge({ + challengeId: input.challengeId, + code: input.code, + oauthSessions: ctx.oauthSessions, + users: ctx.users, + }).catch((error: unknown) => throwKakaoVerificationError(error)); + if (verified.user.deleteAfter) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account deletion is pending.', + }); + } + if (isLoginBanned(verified.user.sanctions)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account login is blocked.', + }); + } + const session = await ctx.sessions.createSession(verified.user); + return { + status: 'login' as const, + user: toPublicUser(verified.user), + sessionToken: session.sessionToken, + issuedAt: session.issuedAt, + validUntil: verified.validUntil, + }; + }), me: procedure .input( z.object({ diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 19bbe980..6aaf3ea1 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -22,6 +22,9 @@ const buildCaller = ( localAccountGraceDays?: number; flushError?: Error; profileListError?: Error; + kakaoId?: string; + kakaoEmail?: string; + allowKakaoRefresh?: boolean; } = {} ) => { const users = createInMemoryUserRepository(); @@ -37,32 +40,48 @@ const buildCaller = ( }), }; const oauthSessions = new InMemoryOAuthSessionStore(); + const kakaoProfile = { + id: options.kakaoId ?? '1', + email: options.kakaoEmail ?? 'tester@example.com', + }; + const sentTalkMessages: string[] = []; + const refreshTokenCalls: string[] = []; const kakaoClient = { restKey: '', redirectUri: '', oauthHost: '', apiHost: '', - buildAuthUrl: (state: string) => `https://kauth.example.test/authorize?state=${state}`, + buildAuthUrl: (state: string, scopes: string[]) => + `https://kauth.example.test/authorize?state=${state}&scope=${scopes.join(',')}`, exchangeCode: async () => ({ accessToken: 'access-token', accessTokenExpiresIn: 3600, refreshToken: 'refresh-token', refreshTokenExpiresIn: 86400, }), - refreshToken: async () => { - throw new Error('not used'); + refreshToken: async (refreshToken: string) => { + refreshTokenCalls.push(refreshToken); + if (!options.allowKakaoRefresh) { + throw new Error('not used'); + } + return { + accessToken: 'refreshed-access-token', + accessTokenExpiresIn: 3600, + }; }, signup: async () => ({ id: '1' }), getMe: async () => ({ - id: '1', + id: kakaoProfile.id, kakaoAccount: { hasEmail: true, - email: 'tester@example.com', + email: kakaoProfile.email, isEmailValid: true, isEmailVerified: true, }, }), - sendTalkMessage: async () => {}, + sendTalkMessage: async (_accessToken: string, message: string) => { + sentTalkMessages.push(message); + }, }; const profileRows = [ { @@ -213,6 +232,9 @@ const buildCaller = ( sessions, flushPublisher, userIconUpload, + kakaoProfile, + sentTalkMessages, + refreshTokenCalls, sealPassword, setSessionHeader: (sessionToken: string) => { requestHeaders['x-session-token'] = sessionToken; @@ -247,6 +269,10 @@ describe('gateway auth flow', () => { username: 'LOCAL-USER', credential: sealPassword('비밀번호-password'), }); + expect(login.status).toBe('login'); + if (login.status !== 'login') { + throw new Error('Expected completed login.'); + } expect(login.user.username).toBe('local-user'); }); @@ -405,7 +431,7 @@ describe('gateway auth flow', () => { }); it('links Kakao to the logged-in local account instead of creating a second user', async () => { - const { caller, users, sealPassword, setSessionHeader } = buildCaller(); + const { caller, users, sealPassword, setSessionHeader, sentTalkMessages } = buildCaller(); const register = await caller.auth.registerLocal({ username: 'verify-user', credential: sealPassword('verify-password'), @@ -421,11 +447,14 @@ describe('gateway auth flow', () => { state: start.state, }); - expect(verified.status).toBe('verified'); - if (verified.status !== 'verified') { - throw new Error('Expected verified result.'); + expect(verified.status).toBe('otp'); + if (verified.status !== 'otp') { + throw new Error('Expected Kakao OTP challenge.'); } - expect(verified.user.kakaoVerified).toBe(true); + const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; + expect(code).toBeTruthy(); + const completed = await caller.auth.kakaoOtp({ challengeId: verified.challengeId, code: code! }); + expect(completed.user.kakaoVerified).toBe(true); const stored = await users.findByUsername('verify-user'); expect(stored).toMatchObject({ oauthType: 'KAKAO', @@ -435,6 +464,230 @@ describe('gateway auth flow', () => { expect(stored?.kakaoVerifiedAt).toBeTruthy(); }); + it('always requests both email and KakaoTalk message consent', async () => { + const { caller } = buildCaller(); + + const start = await caller.auth.kakaoStart({ mode: 'login', scopes: [] }); + + expect(decodeURIComponent(start.authUrl)).toContain('scope=account_email,talk_message'); + }); + + it('rejects a new Kakao identity when its verified email is already registered', async () => { + const { caller, users, kakaoProfile } = buildCaller(); + await users.createUser({ + username: 'email-owner', + password: 'owner-password', + oauth: { + type: 'KAKAO', + id: 'original-kakao-id', + email: 'tester@example.com', + info: {}, + }, + }); + kakaoProfile.id = 'different-kakao-id'; + + const start = await caller.auth.kakaoStart({ mode: 'login' }); + await expect(caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state })).rejects.toMatchObject({ + code: 'CONFLICT', + message: expect.stringContaining('이미 다른 계정에서 사용 중인 카카오 이메일'), + }); + }); + + it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => { + const { caller, users, kakaoProfile } = buildCaller({ + kakaoId: 'stable-kakao-id', + kakaoEmail: 'changed@example.com', + }); + const user = await users.createUser({ + username: 'kakao-email-change', + password: 'email-change-password', + oauth: { + type: 'KAKAO', + id: 'stable-kakao-id', + email: 'before@example.com', + info: {}, + }, + }); + await users.markKakaoTalkVerified(user.id, new Date(Date.now() + 60_000)); + + const start = await caller.auth.kakaoStart({ mode: 'login' }); + const login = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + + expect(login.status).toBe('login'); + expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email); + expect(await users.findByEmail('before@example.com')).toBeNull(); + }); + + it('checks Kakao identity and changed email on password login, then verifies the talk OTP', async () => { + const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({ + kakaoId: 'password-login-kakao-id', + kakaoEmail: 'after-password-login@example.com', + }); + const user = await users.createUser({ + username: 'kakao-password-login', + password: 'kakao-password', + oauth: { + type: 'KAKAO', + id: kakaoProfile.id, + email: 'before-password-login@example.com', + info: { + accessToken: 'stored-access-token', + refreshToken: 'stored-refresh-token', + accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(), + refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(), + }, + }, + }); + + const login = await caller.auth.login({ + username: user.username, + credential: sealPassword('kakao-password'), + }); + expect(login.status).toBe('otp'); + if (login.status !== 'otp') { + throw new Error('Expected Kakao OTP challenge.'); + } + expect((await users.findById(user.id))?.email).toBe(kakaoProfile.email); + expect(sentTalkMessages).toHaveLength(1); + + await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: expect.stringContaining('2회 더 시도'), + }); + const code = sentTalkMessages[0]?.match(/인증 코드는 (\d{4})/)?.[1]; + expect(code).toBeTruthy(); + const completed = await caller.auth.kakaoOtp({ challengeId: login.challengeId, code: code! }); + expect(completed.validUntil).toBeTruthy(); + expect((await users.findById(user.id))?.kakaoTalkVerifiedUntil).toBe(completed.validUntil); + + const nextLogin = await caller.auth.login({ + username: user.username, + credential: sealPassword('kakao-password'), + }); + expect(nextLogin.status).toBe('login'); + expect(sentTalkMessages).toHaveLength(1); + }); + + it('refreshes an expired access token before the password-login identity check', async () => { + const { caller, users, sealPassword, kakaoProfile, refreshTokenCalls } = buildCaller({ + kakaoId: 'refresh-kakao-id', + kakaoEmail: 'refreshed-email@example.com', + allowKakaoRefresh: true, + }); + const user = await users.createUser({ + username: 'refresh-kakao-user', + password: 'refresh-kakao-password', + oauth: { + type: 'KAKAO', + id: kakaoProfile.id, + email: 'old-refresh-email@example.com', + info: { + accessToken: 'expired-access-token', + refreshToken: 'usable-refresh-token', + accessTokenValidUntil: new Date(Date.now() - 60_000).toISOString(), + refreshTokenValidUntil: new Date(Date.now() + 86_400_000).toISOString(), + }, + }, + }); + + const login = await caller.auth.login({ + username: user.username, + credential: sealPassword('refresh-kakao-password'), + }); + + expect(login.status).toBe('otp'); + expect(refreshTokenCalls).toEqual(['usable-refresh-token']); + expect(await users.findById(user.id)).toMatchObject({ + email: 'refreshed-email@example.com', + oauthInfo: { + accessToken: 'refreshed-access-token', + refreshToken: 'usable-refresh-token', + }, + }); + }); + + it('rejects password-login email synchronization when the changed email belongs to another user', async () => { + const { caller, users, sealPassword, kakaoProfile } = buildCaller({ + kakaoId: 'conflicting-email-kakao-id', + kakaoEmail: 'occupied@example.com', + }); + const user = await users.createUser({ + username: 'conflicting-email-user', + password: 'conflicting-email-password', + oauth: { + type: 'KAKAO', + id: kakaoProfile.id, + email: 'previous@example.com', + info: { + accessToken: 'stored-access-token', + accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(), + }, + }, + }); + await users.createUser({ + username: 'occupied-email-owner', + password: 'occupied-email-password', + oauth: { + type: 'KAKAO', + id: 'other-kakao-id', + email: kakaoProfile.email, + info: {}, + }, + }); + + await expect( + caller.auth.login({ + username: user.username, + credential: sealPassword('conflicting-email-password'), + }) + ).rejects.toMatchObject({ + code: 'CONFLICT', + message: expect.stringContaining('이미 다른 계정에서 사용 중'), + }); + expect((await users.findById(user.id))?.email).toBe('previous@example.com'); + }); + + it('reuses the active challenge and blocks retries after three wrong OTP values', async () => { + const { caller, users, sealPassword, kakaoProfile, sentTalkMessages } = buildCaller({ + kakaoId: 'attempt-limit-kakao-id', + }); + const user = await users.createUser({ + username: 'attempt-limit-user', + password: 'attempt-limit-password', + oauth: { + type: 'KAKAO', + id: kakaoProfile.id, + email: kakaoProfile.email, + info: { + accessToken: 'stored-access-token', + accessTokenValidUntil: new Date(Date.now() + 60_000).toISOString(), + }, + }, + }); + const login = await caller.auth.login({ + username: user.username, + credential: sealPassword('attempt-limit-password'), + }); + expect(login.status).toBe('otp'); + if (login.status !== 'otp') { + throw new Error('Expected Kakao OTP challenge.'); + } + + for (const remaining of [2, 1, 0]) { + await expect(caller.auth.kakaoOtp({ challengeId: login.challengeId, code: '0000' })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + message: + remaining > 0 ? expect.stringContaining(`${remaining}회 더 시도`) : expect.stringContaining('초과'), + }); + } + const retried = await caller.auth.login({ + username: user.username, + credential: sealPassword('attempt-limit-password'), + }); + expect(retried).toMatchObject({ status: 'otp', challengeId: login.challengeId, attemptsRemaining: 0 }); + expect(sentTalkMessages).toHaveLength(1); + }); + it('blocks Kakao login while a ban is active', async () => { const { caller, users, sealPassword, setSessionHeader } = buildCaller(); const register = await caller.auth.registerLocal({ @@ -503,7 +756,7 @@ describe('gateway auth flow', () => { }); it('registers and issues a game session', async () => { - const { caller, oauthSessions, sealPassword } = buildCaller(); + const { caller, oauthSessions, sealPassword, sentTalkMessages } = buildCaller(); const oauthSession = await oauthSessions.createSession({ mode: 'login', kakaoId: '1', @@ -524,11 +777,18 @@ describe('gateway auth flow', () => { thirdPartyUse: false, }); - expect(register.user.username).toBe('tester'); - expect(register.sessionToken).toBeTruthy(); + expect(register.status).toBe('otp'); + if (register.status !== 'otp') { + throw new Error('Expected Kakao OTP challenge.'); + } + const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; + expect(code).toBeTruthy(); + const completed = await caller.auth.kakaoOtp({ challengeId: register.challengeId, code: code! }); + expect(completed.user.username).toBe('tester'); + expect(completed.sessionToken).toBeTruthy(); const issued = await caller.auth.issueGameSession({ - sessionToken: register.sessionToken, + sessionToken: completed.sessionToken, profile: 'che:default', }); diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts new file mode 100644 index 00000000..de3c41b2 --- /dev/null +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -0,0 +1,78 @@ +import { randomUUID } from 'node:crypto'; + +import { createClient } from 'redis'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { RedisOAuthSessionStore } from '../src/auth/oauthSessionStore.js'; + +const redisUrl = process.env.GATEWAY_OAUTH_REDIS_TEST_URL; + +describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao login challenge', () => { + const prefix = `gateway-oauth-test:${randomUUID()}`; + const client = createClient({ url: redisUrl }); + const store = new RedisOAuthSessionStore(client, prefix, 300); + const userIds = new Set(); + const challengeIds = new Set(); + + beforeAll(async () => { + await client.connect(); + }); + + afterAll(async () => { + const keys = [ + ...[...challengeIds].map((id) => `${prefix}:kakao-login-challenge:${id}`), + ...[...userIds].map((id) => `${prefix}:kakao-login-challenge-user:${id}`), + ]; + if (keys.length > 0) { + await client.del(keys); + } + await client.quit(); + }); + + const createChallenge = async () => { + const userId = randomUUID(); + userIds.add(userId); + const challenge = await store.createLoginChallenge({ + userId, + code: '4321', + attemptsRemaining: 3, + expiresAt: new Date(Date.now() + 180_000).toISOString(), + createdAt: new Date().toISOString(), + }); + challengeIds.add(challenge.id); + return challenge; + }; + + it('atomically consumes a successful code once', async () => { + const challenge = await createChallenge(); + + const results = await Promise.all([ + store.verifyLoginChallenge(challenge.id, challenge.code), + store.verifyLoginChallenge(challenge.id, challenge.code), + ]); + + expect(results.filter((result) => result.status === 'verified')).toHaveLength(1); + expect(results.filter((result) => result.status === 'expired')).toHaveLength(1); + expect(await store.getLoginChallengeForUser(challenge.userId)).toBeNull(); + }); + + it('atomically limits parallel wrong-code submissions to three attempts', async () => { + const challenge = await createChallenge(); + + const results = await Promise.all( + Array.from({ length: 4 }, () => store.verifyLoginChallenge(challenge.id, '0000')) + ); + + expect(results.filter((result) => result.status === 'mismatch')).toHaveLength(3); + expect(results.filter((result) => result.status === 'locked')).toHaveLength(1); + expect( + results + .filter((result) => result.status === 'mismatch') + .map((result) => result.attemptsRemaining) + .sort() + ).toEqual([0, 1, 2]); + await expect(store.verifyLoginChallenge(challenge.id, challenge.code)).resolves.toMatchObject({ + status: 'locked', + }); + }); +}); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 30e44424..e7d50cfb 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -37,7 +37,7 @@ describe('readReleaseManifest', () => { const workspaceRoot = path.resolve(import.meta.dirname, '../../..'); await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ - gatewaySchemaHead: '20260806000000_add_admin_audit_and_kakao_grace', + gatewaySchemaHead: '20260808000000_add_kakao_talk_verification', gameSchemaHead: '20260803000000_add_logical_game_clock', }); }); diff --git a/app/gateway-frontend/e2e/kakao-otp.spec.ts b/app/gateway-frontend/e2e/kakao-otp.spec.ts new file mode 100644 index 00000000..bd6f67ad --- /dev/null +++ b/app/gateway-frontend/e2e/kakao-otp.spec.ts @@ -0,0 +1,218 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; + +import { expect, test, type Page, type Route } from '@playwright/test'; + +const artifactRoot = process.env.KAKAO_OTP_ARTIFACT_DIR ? resolve(process.env.KAKAO_OTP_ARTIFACT_DIR) : null; +const response = (data: unknown) => ({ result: { data } }); +const errorResponse = (path: string, message: string) => ({ + error: { + message, + code: -32001, + data: { + code: 'UNAUTHORIZED', + httpStatus: 401, + path, + }, + }, +}); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); +const challenge = { + status: 'otp' as const, + challengeId: '11111111-1111-4111-8111-111111111111', + expiresAt: '2026-08-08T06:00:00.000Z', + attemptsRemaining: 3, +}; + +const installFixture = async (page: Page, source: 'password' | 'oauth') => { + let loggedIn = false; + let otpAttempts = 0; + await page.route('**/gateway/api/trpc/**', async (route) => { + const operations = operationNames(route); + const results = await Promise.all( + operations.map(async (operation) => { + if (operation === 'me') { + return response( + loggedIn + ? { + id: 'kakao-otp-user', + username: 'kakao-otp-user', + displayName: '카카오 인증 사용자', + roles: [], + picture: 'default.jpg', + kakaoVerified: true, + kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z', + createdAt: '2026-08-08T00:00:00.000Z', + } + : null + ); + } + if (operation === 'lobby.notice') return response(''); + if (operation === 'lobby.profiles') return response([]); + if (operation === 'auth.passwordKey') { + return response({ keyId: 'playwright-key', publicKeyPem, algorithm: 'RSA-OAEP-256' }); + } + if (operation === 'auth.login') return response({ ...challenge, successStatus: 'login' }); + if (operation === 'auth.kakaoExchange') { + return response({ ...challenge, successStatus: source === 'oauth' ? 'verified' : 'login' }); + } + if (operation === 'auth.kakaoOtp') { + otpAttempts += 1; + await new Promise((resolveDelay) => setTimeout(resolveDelay, 800)); + if (otpAttempts === 1) { + return errorResponse(operation, '인증 번호가 틀렸습니다. 2회 더 시도할 수 있습니다.'); + } + loggedIn = true; + return response({ + status: 'login', + user: { + id: 'kakao-otp-user', + username: 'kakao-otp-user', + displayName: '카카오 인증 사용자', + roles: [], + picture: 'default.jpg', + kakaoVerified: true, + kakaoGraceStartedAt: '2026-08-08T00:00:00.000Z', + createdAt: '2026-08-08T00:00:00.000Z', + }, + sessionToken: 'verified-session-token', + issuedAt: '2026-08-08T05:57:00.000Z', + validUntil: '2026-08-18T05:57:00.000Z', + }); + } + throw new Error(`Unhandled Kakao OTP fixture operation: ${operation}`); + }) + ); + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(results), + }); + }); + return { otpAttempts: () => otpAttempts }; +}; + +const verifyDialog = async (page: Page, artifactName: string) => { + const dialog = page.getByRole('dialog', { name: '인증 코드 필요' }); + const input = dialog.getByLabel('인증 코드'); + const submit = dialog.getByRole('button', { name: '제출' }); + await expect(dialog).toBeVisible(); + await expect(dialog).toContainText("카카오톡의 '나와의 채팅'란을 확인해 주세요."); + await expect(input).toBeFocused(); + + const geometry = await dialog.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + y: rect.y, + width: rect.width, + height: rect.height, + backgroundColor: style.backgroundColor, + border: style.border, + fontSize: style.fontSize, + }; + }); + expect(geometry.width).toBeLessThanOrEqual(500); + expect(geometry.width).toBeGreaterThan(350); + expect(geometry.backgroundColor).toBe('rgb(48, 48, 48)'); + expect(geometry.border).toBe('1px solid rgb(68, 68, 68)'); + + await submit.hover(); + await page.waitForTimeout(200); + expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(55, 90, 127)'); + await input.press('Tab'); + await page.keyboard.press('Tab'); + await expect(submit).toBeFocused(); + await page.waitForTimeout(200); + expect(await submit.evaluate((element) => getComputedStyle(element).boxShadow)).toMatch( + /^rgba\(85, 115, 146, 0\.49\d\) 0px 0px 0px 4px$/ + ); + + if (artifactRoot) { + await mkdir(artifactRoot, { recursive: true }); + await page.screenshot({ path: resolve(artifactRoot, `${artifactName}.png`), fullPage: true }); + await writeFile( + resolve(artifactRoot, `${artifactName}.json`), + `${JSON.stringify(geometry, null, 2)}\n`, + 'utf8' + ); + } + + const verifyPointerActive = async () => { + const box = await submit.boundingBox(); + if (!box) throw new Error('OTP submit button has no rendered geometry.'); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + expect(await submit.evaluate((element) => element.matches(':active'))).toBe(true); + await page.waitForTimeout(200); + expect(await submit.evaluate((element) => getComputedStyle(element).backgroundColor)).toBe('rgb(44, 72, 102)'); + await page.mouse.move(1, 1); + await page.mouse.up(); + }; + const submitAndObserveDisabled = () => + submit.evaluate( + (element) => + new Promise<{ disabled: boolean; opacity: string }>((resolveDisabled) => { + (element as HTMLButtonElement).click(); + setTimeout( + () => + resolveDisabled({ + disabled: (element as HTMLButtonElement).disabled, + opacity: getComputedStyle(element).opacity, + }), + 200 + ); + }) + ); + + await input.fill('0000'); + await verifyPointerActive(); + expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' }); + await expect(dialog.getByRole('alert')).toContainText('2회 더 시도'); + await expect(input).toBeFocused(); + + await input.fill('1234'); + expect(await submitAndObserveDisabled()).toEqual({ disabled: true, opacity: '0.65' }); + await expect(page).toHaveURL(/\/gateway\/lobby(?:\?verified=1)?$/); + await expect + .poll(() => page.evaluate(() => window.localStorage.getItem('sammo-session-token'))) + .toBe('verified-session-token'); + return geometry; +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +] as const) { + test(`completes password-login KakaoTalk OTP on ${viewport.name}`, async ({ page }) => { + const fixture = await installFixture(page, 'password'); + await page.setViewportSize(viewport); + await page.goto('/gateway/'); + await page.getByLabel('계정명').fill('kakao-otp-user'); + await page.getByLabel('비밀번호').fill('password-for-browser-fixture'); + await page.getByRole('button', { name: '로그인', exact: true }).click(); + + const geometry = await verifyDialog(page, `kakao-otp-password-${viewport.name}`); + expect(geometry.width).toBe(viewport.name === 'desktop' ? 500 : 374); + expect(geometry.y).toBe(viewport.name === 'desktop' ? 28 : 8); + expect(fixture.otpAttempts()).toBe(2); + }); +} + +test('completes the same KakaoTalk OTP flow after OAuth callback', async ({ page }) => { + const fixture = await installFixture(page, 'oauth'); + await page.setViewportSize({ width: 1200, height: 900 }); + await page.goto('/gateway/oauth/callback?code=oauth-code&state=oauth-state'); + + await verifyDialog(page, 'kakao-otp-oauth-callback'); + await expect(page).toHaveURL(/\/gateway\/lobby\?verified=1$/); + expect(fixture.otpAttempts()).toBe(2); +}); diff --git a/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts b/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts index b1a98013..7efe4cf7 100644 --- a/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts @@ -60,6 +60,9 @@ const loginGame = async (username: string, password: string) => { ).toString('base64'), }; const login = await gateway.auth.login.mutate({ username, credential }); + if (login.status === 'otp') { + throw new Error('Live lifecycle fixture requires a currently verified KakaoTalk login.'); + } gatewaySession.token = login.sessionToken; const issued = await gateway.auth.issueGameSession.mutate({ sessionToken: login.sessionToken, diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 86e92297..f242191d 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -16,6 +16,7 @@ export default defineConfig({ 'account-icon-sync.spec.ts', 'legacy-log-html.spec.ts', 'gateway-notice-html.spec.ts', + 'kakao-otp.spec.ts', ], fullyParallel: false, workers: 1, diff --git a/app/gateway-frontend/src/components/KakaoOtpDialog.vue b/app/gateway-frontend/src/components/KakaoOtpDialog.vue new file mode 100644 index 00000000..7a2c220a --- /dev/null +++ b/app/gateway-frontend/src/components/KakaoOtpDialog.vue @@ -0,0 +1,257 @@ + + + + + diff --git a/app/gateway-frontend/src/views/HomeView.vue b/app/gateway-frontend/src/views/HomeView.vue index 304107f1..1620d7e9 100644 --- a/app/gateway-frontend/src/views/HomeView.vue +++ b/app/gateway-frontend/src/views/HomeView.vue @@ -5,6 +5,7 @@ import type { inferRouterOutputs } from '@trpc/server'; import type { AppRouter } from '@sammo-ts/gateway-api'; import MapPreview from '../components/MapPreview.vue'; +import KakaoOtpDialog from '../components/KakaoOtpDialog.vue'; import DefaultLayout from '../layouts/DefaultLayout.vue'; import { createGameTrpc, type GameRouter } from '../utils/gameTrpc'; import { trpc } from '../utils/trpc'; @@ -24,6 +25,7 @@ const username = ref(''); const password = ref(''); const loginError = ref(''); const loginLoading = ref(false); +const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null); const statusLoading = ref(false); const statusError = ref(''); const profile = ref(null); @@ -92,6 +94,10 @@ const handleLogin = async (): Promise => { username: username.value, credential, }); + if (result.status === 'otp') { + otpChallenge.value = result; + return; + } window.localStorage.setItem('sammo-session-token', result.sessionToken); await router.push('/lobby'); } catch (error) { @@ -101,6 +107,12 @@ const handleLogin = async (): Promise => { } }; +const handleOtpVerified = async (sessionToken: string): Promise => { + window.localStorage.setItem('sammo-session-token', sessionToken); + otpChallenge.value = null; + await router.push('/lobby'); +}; + const handleKakao = async (): Promise => { loginError.value = ''; try { @@ -191,6 +203,12 @@ const handlePasswordReset = async (): Promise => { +