diff --git a/README.md b/README.md index 2824feb9..a311e43f 100644 --- a/README.md +++ b/README.md @@ -58,8 +58,13 @@ Gateway 자체 릴리스는 Gateway 프로세스 밖의 `release-controller`가 Kakao 계정은 OAuth callback과 일반 비밀번호 로그인 모두에서 Kakao 고유 ID와 현재 인증 이메일을 다시 확인합니다. 로컬 고유 ID 연결이 없지만 영구 보존된 이메일 계정이 있으면 자동 로그인하지 않고 그 계정에 연결할지 묻습니다. 기존 -계정 연결을 확인하면 이전 Kakao ID와 과거 talk proof를 교체하고 새 -“나와의 채팅” 숫자 코드로 다시 증명한 뒤에만 Gateway session을 발급합니다. +계정 연결은 새 “나와의 채팅” 숫자 코드 인증까지 끝난 뒤에만 저장하며, 그 전에는 +이전 연결과 비밀번호를 유지합니다. 일반 계정은 카카오가 인증한 동일 이메일로 +관리자 승인 없이 복구하고, 기존 서비스 비밀번호로 확인하면 이메일이 달라도 +새 카카오에 연결할 수 있습니다. 관리자 계정 교체에는 승인을 유지하며 다른 계정과의 +연결·이메일 충돌은 자동 처리하지 않습니다. 새 교체부터 이전 Kakao ID는 원래 계정의 +복구 이력으로 보존하여 같은 계정에만 다시 연결할 수 있습니다. 기존에 영구 폐기된 +ID는 계속 차단합니다. Kakao가 `already registered`를 반환했지만 보존 이메일 계정도 없으면 재가입 확인 뒤 신규 가입 form을 엽니다. 이미 로컬에 연결된 stable ID의 변경 이메일이 다른 계정에 있으면 기존처럼 충돌을 거부합니다. diff --git a/app/gateway-api/src/auth/attemptBudget.ts b/app/gateway-api/src/auth/attemptBudget.ts index e869aadc..81723d12 100644 --- a/app/gateway-api/src/auth/attemptBudget.ts +++ b/app/gateway-api/src/auth/attemptBudget.ts @@ -103,6 +103,7 @@ const EXPENSIVE_ACTIONS = new Set([ 'auth.bootstrapLocal', 'auth.kakaoExchange', 'auth.kakaoResolveAccount', + 'auth.kakaoLinkWithPassword', 'auth.kakaoSetPassword', 'auth.register', 'auth.registerLocal', diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index f56f36c9..a9d582a7 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -1,3 +1,6 @@ +import { authorizeKakaoLink, matchesKakaoLinkSnapshot } from './kakaoLinkPolicy.js'; +import { assertLoginAllowed } from './deletionRecovery.js'; +import type { CompleteKakaoLinkInput } from './userRepository.js'; import { randomUUID } from 'node:crypto'; import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js'; @@ -29,6 +32,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const usersByOauthId = new Map(); const usersByEmail = new Map(); const retiredKakaoIds = new Set(); + const detachedKakaoOwners = new Map(); const iconsById = new Map(); const specialAccessGrantsById = new Map(); @@ -42,6 +46,62 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass ).toISOString(); return { + async findDetachedKakaoOwner(oauthId: string): Promise { + return detachedKakaoOwners.get(oauthId) ?? null; + }, + async completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise { + const password = input.password ? await hasher.hash(input.password) : undefined; + const user = [...usersByName.values()].find((row) => row.id === userId); + if (!user || !matchesKakaoLinkSnapshot(user, input.expected)) + throw new Error('Kakao recovery account changed.'); + assertLoginAllowed(user); + if ( + authorizeKakaoLink( + user, + input.email, + input.authorization === 'PASSWORD', + input.firstUserId, + input.verifiedAt + ) !== input.authorization + ) + throw new Error('Kakao recovery authorization changed.'); + const email = input.email.toLowerCase(); + const owner = usersByOauthId.get(`KAKAO:${input.oauthId}`); + const emailOwner = usersByEmail.get(email); + const detached = detachedKakaoOwners.get(input.oauthId); + if ( + retiredKakaoIds.has(input.oauthId) || + (detached && detached !== userId) || + (owner && owner.id !== userId) || + (emailOwner && emailOwner.id !== userId) + ) + throw new Error('Kakao recovery identity ownership changed.'); + if (user.passwordResetRequired && !password) throw new Error('A new password is required.'); + if (user.oauthId && user.oauthId !== input.oauthId) { + detachedKakaoOwners.set(user.oauthId, userId); + usersByOauthId.delete(`KAKAO:${user.oauthId}`); + } + if (user.email) usersByEmail.delete(user.email.toLowerCase()); + Object.assign(user, { + oauthType: 'KAKAO', + oauthId: input.oauthId, + email, + oauthInfo: input.oauthInfo, + kakaoVerifiedAt: input.verifiedAt.toISOString(), + kakaoTalkVerifiedUntil: input.talkVerifiedUntil.toISOString(), + authRevision: (user.authRevision ?? 0) + 1, + sessionRevokedBefore: input.verifiedAt.toISOString(), + kakaoReplacementApprovedUntil: undefined, + kakaoReplacementApprovedByUserId: undefined, + kakaoReplacementReason: undefined, + ...(password + ? { passwordHash: password.hash, passwordSalt: password.salt, passwordResetRequired: false } + : {}), + }); + usersByOauthId.set(`KAKAO:${input.oauthId}`, user); + usersByEmail.set(email, user); + return user; + }, async findById(id: string): Promise { for (const user of usersByName.values()) { if (user.id === id) { @@ -103,6 +163,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass if ( input.oauth && (retiredKakaoIds.has(input.oauth.id) || + detachedKakaoOwners.has(input.oauth.id) || usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) || usersByEmail.has(input.oauth.email.toLowerCase())) ) { @@ -204,9 +265,19 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass } throw new Error('User not found.'); }, - async markKakaoTalkVerified(userId: string, validUntil: Date): Promise { + async markKakaoTalkVerified( + userId: string, + validUntil: Date, + authRevision?: number, + oauthId?: string + ): Promise { for (const user of usersByName.values()) { if (user.id === userId) { + if ( + (authRevision !== undefined && (user.authRevision ?? 0) !== authRevision) || + (oauthId && user.oauthId !== oauthId) + ) + throw new Error('Kakao identity changed.'); user.kakaoTalkVerifiedUntil = validUntil.toISOString(); return user; } @@ -305,6 +376,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass !user.kakaoReplacementApprovedByUserId || !user.kakaoReplacementReason || retiredKakaoIds.has(input.oauthId) || + (detachedKakaoOwners.has(input.oauthId) && detachedKakaoOwners.get(input.oauthId) !== userId) || (oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId) ) { diff --git a/app/gateway-api/src/auth/kakaoAccountVerification.ts b/app/gateway-api/src/auth/kakaoAccountVerification.ts index e53c8524..ce7b75d7 100644 --- a/app/gateway-api/src/auth/kakaoAccountVerification.ts +++ b/app/gateway-api/src/auth/kakaoAccountVerification.ts @@ -194,16 +194,17 @@ export const requireKakaoTalkProof = async (options: { kakaoClient: KakaoOAuthClient; oauthSessions: OAuthSessionStore; publicBaseUrl: string; + oauthSessionId?: string; now?: Date; }): Promise => { const now = options.now ?? new Date(); const validUntil = parseDate(options.user.kakaoTalkVerifiedUntil); - if (validUntil && isAfter(validUntil, now)) { + if (!options.oauthSessionId && validUntil && isAfter(validUntil, now)) { return null; } const active = await options.oauthSessions.getLoginChallengeForUser(options.user.id); - if (active) { + if (active && active.oauthSessionId === options.oauthSessionId) { return { status: 'otp', challengeId: active.id, @@ -215,6 +216,9 @@ export const requireKakaoTalkProof = async (options: { const expiresAt = addSeconds(now, KAKAO_OTP_TTL_SECONDS); const challenge = await options.oauthSessions.createLoginChallenge({ userId: options.user.id, + authRevision: options.user.authRevision ?? 0, + ...(options.user.oauthId ? { oauthId: options.user.oauthId } : {}), + ...(options.oauthSessionId ? { oauthSessionId: options.oauthSessionId } : {}), code: String(randomInt(1000, 10_000)), attemptsRemaining: KAKAO_OTP_ATTEMPTS, expiresAt: expiresAt.toISOString(), @@ -246,7 +250,7 @@ export const verifyKakaoTalkChallenge = async (options: { oauthSessions: OAuthSessionStore; users: UserRepository; now?: Date; -}): Promise<{ user: UserRecord; validUntil: string }> => { +}): Promise<{ user: UserRecord; validUntil: string; pendingLinkSessionId?: string }> => { const now = options.now ?? new Date(); const result = await options.oauthSessions.verifyLoginChallenge(options.challengeId, options.code, now); if (result.status === 'expired') { @@ -268,11 +272,27 @@ export const verifyKakaoTalkChallenge = async (options: { } const user = await options.users.findById(result.userId); - if (!user || user.oauthType !== 'KAKAO' || !user.oauthId) { + if (!user || (!result.oauthSessionId && (user.oauthType !== 'KAKAO' || !user.oauthId))) { throw new KakaoVerificationError('REAUTH_REQUIRED', '카카오 계정 연결 정보를 찾지 못했습니다.'); } + if ( + result.authRevision === undefined || + result.authRevision !== (user.authRevision ?? 0) || + (result.oauthId && result.oauthId !== user.oauthId) + ) + throw new KakaoVerificationError('REAUTH_REQUIRED', '계정 인증 정보가 변경되었습니다. 다시 로그인해 주세요.'); const proofValidUntil = addDays(now, KAKAO_TALK_VERIFICATION_DAYS); - const verified = await options.users.markKakaoTalkVerified(user.id, proofValidUntil); + if (result.oauthSessionId) + return { user, validUntil: proofValidUntil.toISOString(), pendingLinkSessionId: result.oauthSessionId }; + const verified = await options.users + .markKakaoTalkVerified(user.id, proofValidUntil, result.authRevision, result.oauthId) + .catch((error: unknown) => { + throw new KakaoVerificationError( + 'REAUTH_REQUIRED', + '계정 인증 정보가 변경되었습니다. 다시 로그인해 주세요.', + { cause: error } + ); + }); return { user: verified, validUntil: proofValidUntil.toISOString() }; }; diff --git a/app/gateway-api/src/auth/kakaoLinkPolicy.ts b/app/gateway-api/src/auth/kakaoLinkPolicy.ts new file mode 100644 index 00000000..9accc638 --- /dev/null +++ b/app/gateway-api/src/auth/kakaoLinkPolicy.ts @@ -0,0 +1,62 @@ +import { createHash } from 'node:crypto'; +import { TRPCError } from '@trpc/server'; +import type { UserRecord } from './userRepository.js'; + +export type KakaoLinkAuthorization = 'EMAIL' | 'PASSWORD' | 'ADMIN'; +export interface KakaoLinkSnapshot { + authRevision: number; + oauthType: UserRecord['oauthType']; + oauthId?: string; + email?: string; + passwordFingerprint: string; +} +export const snapshotKakaoLink = (user: UserRecord): KakaoLinkSnapshot => ({ + authRevision: user.authRevision ?? 0, + oauthType: user.oauthType, + oauthId: user.oauthId, + email: user.email, + passwordFingerprint: createHash('sha256').update(user.passwordHash).digest('hex'), +}); +export const matchesKakaoLinkSnapshot = (user: UserRecord, expected: KakaoLinkSnapshot): boolean => + (user.authRevision ?? 0) === expected.authRevision && + user.oauthType === expected.oauthType && + user.oauthId === expected.oauthId && + user.email === expected.email && + createHash('sha256').update(user.passwordHash).digest('hex') === expected.passwordFingerprint; + +export const authorizeKakaoLink = ( + user: UserRecord, + email: string, + passwordVerified: boolean, + firstUserId: string | undefined, + now = new Date() +): KakaoLinkAuthorization => { + const privileged = + user.id === firstUserId || + user.roles.some((role) => role === 'superuser' || role === 'admin' || role.startsWith('admin.')); + const approved = Boolean( + user.kakaoReplacementApprovedByUserId && + user.kakaoReplacementReason && + user.kakaoReplacementApprovedUntil && + new Date(user.kakaoReplacementApprovedUntil) > now + ); + if (privileged && user.oauthType === 'KAKAO' && user.oauthId) { + if (approved) return 'ADMIN'; + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '관리자 계정의 카카오 교체에는 관리자 승인이 필요합니다.', + }); + } + if (passwordVerified) return 'PASSWORD'; + if (privileged) + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '관리자 계정의 최초 카카오 연결은 기존 비밀번호를 확인해야 합니다.', + }); + if (!privileged && user.email?.toLowerCase() === email.toLowerCase()) return 'EMAIL'; + if (approved) return 'ADMIN'; + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '이메일이 다른 계정은 기존 비밀번호로 확인하거나 관리자에게 복구를 요청해 주세요.', + }); +}; diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index b70a808d..adb521eb 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -1,5 +1,7 @@ import { randomUUID } from 'node:crypto'; import { parseJson } from '@sammo-ts/common'; +import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js'; +import type { PasswordEnvelopeInput } from './passwordEnvelope.js'; export type OAuthMode = 'login' | 'change_pw' | 'verify'; @@ -22,7 +24,12 @@ export interface OAuthPendingState { export interface OAuthSession { id: string; mode: OAuthMode; - intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup'; + intent?: 'register' | 'link_existing' | 'rejoin' | 'password_setup' | 'identity_link'; + identityLink?: { + expected: KakaoLinkSnapshot; + authorization: KakaoLinkAuthorization; + credential?: PasswordEnvelopeInput; + }; targetUserId?: string; kakaoId: string; email: string; @@ -34,6 +41,9 @@ export interface OAuthSession { } export interface KakaoLoginChallenge { + authRevision?: number; + oauthId?: string; + oauthSessionId?: string; id: string; userId: string; code: string; @@ -43,7 +53,7 @@ export interface KakaoLoginChallenge { } export type KakaoLoginChallengeResult = - | { status: 'verified'; userId: string } + | { status: 'verified'; userId: string; oauthSessionId?: string; authRevision?: number; oauthId?: string } | { status: 'mismatch'; attemptsRemaining: number; expiresAt: string } | { status: 'locked'; expiresAt: string } | { status: 'expired' }; @@ -55,6 +65,7 @@ export interface OAuthSessionStore { consumePendingState(state: string): Promise; createSession(session: Omit): Promise; consumeSession(sessionId: string): Promise; + getSession(sessionId: string): Promise; getLoginChallengeForUser(userId: string): Promise; createLoginChallenge(challenge: Omit): Promise; verifyLoginChallenge(challengeId: string, code: string, now?: Date): Promise; @@ -100,7 +111,7 @@ 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 }) +return cjson.encode({ status = 'verified', userId = challenge.userId, oauthSessionId = challenge.oauthSessionId, authRevision = challenge.authRevision, oauthId = challenge.oauthId }) `; const consumeOnceScript = ` @@ -188,6 +199,11 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { return stored; } + async getSession(sessionId: string): Promise { + const raw = await this.client.get(this.sessionKey(sessionId)); + return raw ? parseJson(raw) : null; + } + async consumeSession(sessionId: string): Promise { const key = this.sessionKey(sessionId); const raw = await this.client.eval(consumeOnceScript, { keys: [key], arguments: [] }); @@ -296,6 +312,10 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore { return stored; } + async getSession(sessionId: string): Promise { + return this.sessions.get(sessionId) ?? null; + } + async consumeSession(sessionId: string): Promise { const session = this.sessions.get(sessionId) ?? null; if (session) { @@ -357,6 +377,12 @@ export class InMemoryOAuthSessionStore implements OAuthSessionStore { if (this.userLoginChallenges.get(challenge.userId) === challengeId) { this.userLoginChallenges.delete(challenge.userId); } - return { status: 'verified', userId: challenge.userId }; + return { + status: 'verified', + userId: challenge.userId, + ...(challenge.authRevision !== undefined ? { authRevision: challenge.authRevision } : {}), + ...(challenge.oauthId ? { oauthId: challenge.oauthId } : {}), + ...(challenge.oauthSessionId ? { oauthSessionId: challenge.oauthSessionId } : {}), + }; } } diff --git a/app/gateway-api/src/auth/pendingKakaoLink.ts b/app/gateway-api/src/auth/pendingKakaoLink.ts new file mode 100644 index 00000000..ee3b1644 --- /dev/null +++ b/app/gateway-api/src/auth/pendingKakaoLink.ts @@ -0,0 +1,144 @@ +import { TRPCError } from '@trpc/server'; +import type { GatewayApiContext } from '../context.js'; +import type { OAuthSession } from './oauthSessionStore.js'; +import type { UserRecord } from './userRepository.js'; +import { assertLoginAllowed, completeGatewayLogin } from './deletionRecovery.js'; +import { authorizeKakaoLink, snapshotKakaoLink } from './kakaoLinkPolicy.js'; +import { KakaoVerificationError, readVerifiedKakaoProfile, requireKakaoTalkProof } from './kakaoAccountVerification.js'; +import { openPassword } from './registrationInput.js'; + +const LINK_TTL_MS = 300_000; +const firstUserId = async (ctx: GatewayApiContext): Promise => + (await ctx.prisma.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true } }))?.id; + +export const validatePendingKakaoProfile = async (ctx: GatewayApiContext, session: OAuthSession): Promise => { + const createdAt = Date.parse(session.createdAt); + if (!Number.isFinite(createdAt) || Date.now() - createdAt > LINK_TTL_MS || createdAt > Date.now()) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '카카오 연결 확인이 만료되었습니다. 카카오 로그인을 다시 진행해 주세요.', + }); + } + const profile = await ctx.kakaoClient + .getMe(session.accessToken) + .then(readVerifiedKakaoProfile) + .catch((error: unknown) => { + if (error instanceof KakaoVerificationError) + throw new TRPCError({ code: 'UNAUTHORIZED', message: error.message, cause: error }); + throw error; + }); + if (profile.kakaoId !== session.kakaoId || profile.email !== session.email.toLowerCase()) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '카카오 계정 정보가 변경되었습니다. 처음부터 다시 진행해 주세요.', + }); + } +}; + +export const requestPendingKakaoOtp = async (ctx: GatewayApiContext, session: OAuthSession, user: UserRecord) => { + assertLoginAllowed(user); + const challenge = await requireKakaoTalkProof({ + user, + accessToken: session.accessToken, + kakaoClient: ctx.kakaoClient, + oauthSessions: ctx.oauthSessions, + publicBaseUrl: ctx.publicBaseUrl, + oauthSessionId: session.id, + }).catch((error: unknown) => { + if (error instanceof KakaoVerificationError) + throw new TRPCError({ code: 'PRECONDITION_FAILED', message: error.message, cause: error }); + throw error; + }); + if (!challenge) throw new TRPCError({ code: 'INTERNAL_SERVER_ERROR' }); + return { ...challenge, successStatus: session.mode === 'verify' ? ('verified' as const) : ('login' as const) }; +}; + +export const beginPendingKakaoLink = async ( + ctx: GatewayApiContext, + user: UserRecord, + oauth: OAuthSession, + passwordVerified = false +) => { + await validatePendingKakaoProfile(ctx, oauth); + assertLoginAllowed(user); + const authorization = authorizeKakaoLink(user, oauth.email, passwordVerified, await firstUserId(ctx)); + const [owner, detached] = await Promise.all([ + ctx.users.findByOauthId('KAKAO', oauth.kakaoId), + ctx.users.findDetachedKakaoOwner(oauth.kakaoId), + ]); + if ( + (owner && owner.id !== user.id) || + (detached && detached !== user.id) || + (await ctx.users.isKakaoIdentityRetired(oauth.kakaoId)) + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '다른 계정에 귀속된 카카오 계정입니다. 관리자에게 문의해 주세요.', + }); + } + const session = await ctx.oauthSessions.createSession({ + ...oauth, + intent: 'identity_link', + targetUserId: user.id, + createdAt: new Date().toISOString(), + identityLink: { expected: snapshotKakaoLink(user), authorization }, + }); + if (user.passwordResetRequired) { + return { + status: 'password_setup' as const, + oauthSessionId: session.id, + email: session.email, + successStatus: session.mode === 'verify' ? ('verified' as const) : ('login' as const), + }; + } + return requestPendingKakaoOtp(ctx, session, user); +}; + +export const finishPendingKakaoLink = async ( + ctx: GatewayApiContext, + sessionId: string, + userId: string, + validUntil: string +) => { + const pending = await ctx.oauthSessions.consumeSession(sessionId); + if (!pending || pending.intent !== 'identity_link' || !pending.identityLink || pending.targetUserId !== userId) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.', + }); + } + await validatePendingKakaoProfile(ctx, pending); + let user: UserRecord; + try { + user = await ctx.users.completeKakaoIdentityLink(userId, { + ...pending.identityLink, + oauthId: pending.kakaoId, + email: pending.email, + oauthInfo: { + accessToken: pending.accessToken, + refreshToken: pending.refreshToken, + accessTokenValidUntil: pending.accessTokenValidUntil, + refreshTokenValidUntil: pending.refreshTokenValidUntil, + }, + verifiedAt: new Date(), + talkVerifiedUntil: new Date(validUntil), + firstUserId: await firstUserId(ctx), + ...(pending.identityLink.credential + ? { password: openPassword(ctx.passwordEnvelope, pending.identityLink.credential) } + : {}), + }); + } catch (error) { + if (error instanceof TRPCError) throw error; + throw new TRPCError({ + code: 'CONFLICT', + message: '계정 연결 상태가 변경되었습니다. 카카오 로그인을 다시 진행해 주세요.', + cause: error, + }); + } + try { + await ctx.flushPublisher.publishUserFlush(user.id, 'kakao-account-relinked'); + } catch { + // DB의 인증 revision은 이미 증가했다. 다음 접속의 재검증을 유지하고 저장 성공을 반환한다. + } + return { ...(await completeGatewayLogin(ctx, user, 'login')), validUntil }; +}; diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index d781fb76..c21845d1 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -1,3 +1,6 @@ +import { authorizeKakaoLink, matchesKakaoLinkSnapshot } from './kakaoLinkPolicy.js'; +import { assertLoginAllowed } from './deletionRecovery.js'; +import type { CompleteKakaoLinkInput } from './userRepository.js'; import { GatewayPrisma, type GatewayPrismaClient } from '@sammo-ts/infra'; import { createPasswordHasher, type PasswordHasher } from './passwordHasher.js'; @@ -175,6 +178,91 @@ export const createPostgresUserRepository = ( hasher: PasswordHasher = createPasswordHasher() ): UserRepository => { return { + async findDetachedKakaoOwner(oauthId: string): Promise { + const row = await prisma.kakaoIdentityLinkHistory.findFirst({ + where: { previousOauthId: oauthId }, + orderBy: { createdAt: 'desc' }, + select: { userId: true }, + }); + return row?.userId ?? null; + }, + async completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise { + const password = input.password ? await hasher.hash(input.password) : undefined; + return prisma.$transaction( + async (tx) => { + const row = await tx.appUser.findUnique({ where: { id: userId } }); + if (!row) throw new Error('Kakao recovery account no longer exists.'); + const user = mapUser(row); + assertLoginAllowed(user); + if (!matchesKakaoLinkSnapshot(user, input.expected)) + throw new Error('Kakao recovery account changed.'); + const first = await tx.appUser.findFirst({ orderBy: { createdAt: 'asc' }, select: { id: true } }); + if ( + authorizeKakaoLink( + user, + input.email, + input.authorization === 'PASSWORD', + first?.id, + input.verifiedAt + ) !== input.authorization + ) + throw new Error('Kakao recovery authorization changed.'); + if (user.passwordResetRequired && !password) throw new Error('A new password is required.'); + const [owner, emailOwner, retired, detached] = await Promise.all([ + tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), + tx.appUser.findUnique({ where: { email: input.email.toLowerCase() }, select: { id: true } }), + tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), + tx.kakaoIdentityLinkHistory.findFirst({ + where: { previousOauthId: input.oauthId, userId: { not: userId } }, + }), + ]); + if ( + retired || + detached || + (owner && owner.id !== userId) || + (emailOwner && emailOwner.id !== userId) + ) + throw new Error('Kakao recovery identity ownership changed.'); + if (user.oauthId && user.oauthId !== input.oauthId) { + await tx.kakaoIdentityLinkHistory.create({ + data: { + userId, + previousOauthId: user.oauthId, + nextOauthId: input.oauthId, + authorization: input.authorization, + }, + }); + } + // OTP까지 확인한 새 연결과 비밀번호/인증 revision을 하나의 transaction에 저장한다. + return mapUser( + await tx.appUser.update({ + where: { id: userId }, + data: { + oauthType: 'KAKAO', + oauthId: input.oauthId, + email: input.email.toLowerCase(), + oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, + kakaoVerifiedAt: input.verifiedAt, + kakaoTalkVerifiedUntil: input.talkVerifiedUntil, + authRevision: { increment: 1 }, + sessionRevokedBefore: input.verifiedAt, + kakaoReplacementApprovedUntil: null, + kakaoReplacementApprovedByUserId: null, + kakaoReplacementReason: null, + ...(password + ? { + passwordHash: password.hash, + passwordSalt: password.salt, + passwordResetRequired: false, + } + : {}), + }, + }) + ); + }, + { isolationLevel: 'Serializable' } + ); + }, async findById(id: string): Promise { const row = await prisma.appUser.findUnique({ where: { @@ -286,6 +374,12 @@ export const createPostgresUserRepository = ( if (retired) { throw new Error('Kakao identity is permanently retired.'); } + if ( + await tx.kakaoIdentityLinkHistory.findFirst({ + where: { previousOauthId: input.oauth!.id }, + }) + ) + throw new Error('Kakao identity belongs to a former service account.'); return tx.appUser.create({ data }); }, { isolationLevel: 'Serializable' } @@ -340,9 +434,18 @@ export const createPostgresUserRepository = ( }); return mapUser(row); }, - async markKakaoTalkVerified(userId: string, validUntil: Date): Promise { + async markKakaoTalkVerified( + userId: string, + validUntil: Date, + authRevision?: number, + oauthId?: string + ): Promise { const row = await prisma.appUser.update({ - where: { id: userId }, + where: { + id: userId, + ...(authRevision !== undefined ? { authRevision } : {}), + ...(oauthId ? { oauthId } : {}), + }, data: { kakaoTalkVerifiedUntil: validUntil }, }); return mapUser(row); @@ -357,6 +460,12 @@ export const createPostgresUserRepository = ( if (retired) { throw new Error('Kakao identity is permanently retired.'); } + if ( + await tx.kakaoIdentityLinkHistory.findFirst({ + where: { previousOauthId: input.oauthId, userId: { not: userId } }, + }) + ) + throw new Error('Kakao identity belongs to another service account.'); const row = await tx.appUser.update({ where: { id: userId }, data: { @@ -384,6 +493,12 @@ export const createPostgresUserRepository = ( if (retired) { throw new Error('Kakao identity is permanently retired.'); } + if ( + await tx.kakaoIdentityLinkHistory.findFirst({ + where: { previousOauthId: input.oauthId, userId: { not: userId } }, + }) + ) + throw new Error('Kakao identity belongs to another service account.'); const updated = await tx.appUser.updateMany({ where: { id: userId, @@ -444,13 +559,17 @@ export const createPostgresUserRepository = ( throw new Error('Kakao account replacement is not approved.'); } const normalizedEmail = input.email.toLowerCase(); - const [oauthOwner, emailOwner, retired] = await Promise.all([ + const [oauthOwner, emailOwner, retired, detached] = await Promise.all([ tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }), tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), + tx.kakaoIdentityLinkHistory.findFirst({ + where: { previousOauthId: input.oauthId, userId: { not: userId } }, + }), ]); if ( retired || + detached || (oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId) ) { diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 721247c2..ceb30e69 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -1,3 +1,4 @@ +import type { KakaoLinkAuthorization, KakaoLinkSnapshot } from './kakaoLinkPolicy.js'; export interface UserRecord { id: string; username: string; @@ -155,7 +156,21 @@ export interface CreateUserInput { }; } +export interface CompleteKakaoLinkInput { + firstUserId?: string; + expected: KakaoLinkSnapshot; + authorization: KakaoLinkAuthorization; + oauthId: string; + email: string; + oauthInfo: UserOAuthInfo; + verifiedAt: Date; + talkVerifiedUntil: Date; + password?: string; +} + export interface UserRepository { + findDetachedKakaoOwner(oauthId: string): Promise; + completeKakaoIdentityLink(userId: string, input: CompleteKakaoLinkInput): Promise; findById(id: string): Promise; findByIds(ids: string[]): Promise; findByUsername(username: string): Promise; @@ -168,7 +183,12 @@ export interface UserRepository { 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; + markKakaoTalkVerified( + userId: string, + validUntil: Date, + authRevision?: number, + oauthId?: string + ): Promise; linkKakao( userId: string, input: { diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 47fbb9b8..887e40ca 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -24,6 +24,12 @@ import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js'; import { isGatewaySessionCurrent } from './auth/sessionValidity.js'; import { GameSessionLimitError } from './auth/sessionService.js'; import { assertLoginAllowed, canCancelDeletion, completeGatewayLogin } from './auth/deletionRecovery.js'; +import { + beginPendingKakaoLink, + finishPendingKakaoLink, + requestPendingKakaoOtp, + validatePendingKakaoProfile, +} from './auth/pendingKakaoLink.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; import type { GatewayApiContext } from './context.js'; import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; @@ -394,57 +400,30 @@ export const appRouter = router({ }); } const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo); - let verified: UserRecord; - const replacingKakao = - localUser.oauthType === 'KAKAO' && - Boolean(localUser.oauthId) && - localUser.oauthId !== profile.kakaoId; - if (replacingKakao) { - try { - verified = await ctx.users.replaceKakaoWithApprovedIdentity(localUser.id, { - oauthId: profile.kakaoId, - email: profile.email, - oauthInfo, - verifiedAt: new Date(), - }); - } catch (error) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: '관리자의 카카오 계정 교체 승인이 없거나 만료되었습니다.', - cause: error, - }); - } - } else if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') { - try { - verified = await ctx.users.linkKakao(localUser.id, { - oauthId: profile.kakaoId, - email: profile.email, - oauthInfo, - verifiedAt: new Date(), - }); - } catch (error) { - throw new TRPCError({ - code: 'CONFLICT', - message: '이미 다른 계정에 연결된 카카오 계정입니다.', - cause: error, - }); - } - } else { - try { - verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo); - } catch (error) { - throw new TRPCError({ - code: 'CONFLICT', - message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', - cause: error, - }); - } + if (localUser.oauthType !== 'KAKAO' || localUser.oauthId !== profile.kakaoId) { + const stored = await ctx.oauthSessions.createSession({ + mode: pending.mode, + intent: 'link_existing', + targetUserId: localUser.id, + kakaoId: profile.kakaoId, + email: profile.email, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + accessTokenValidUntil: oauthInfo.accessTokenValidUntil!, + refreshTokenValidUntil: oauthInfo.refreshTokenValidUntil, + createdAt: new Date().toISOString(), + }); + return { + status: 'account_recovery' as const, + action: 'link_existing' as const, + oauthSessionId: stored.id, + email: stored.email, + passwordRequired: localUser.email?.toLowerCase() !== profile.email, + }; } + const verified = await ctx.users.syncKakaoIdentity(localUser.id, profile.email, oauthInfo); const refreshed = (await ctx.users.findById(verified.id)) ?? verified; - await ctx.flushPublisher.publishUserFlush( - refreshed.id, - replacingKakao ? 'kakao-account-replaced' : 'kakao-verified' - ); + await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified'); return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified'); } @@ -515,8 +494,20 @@ export const appRouter = router({ return finishKakaoLoginOrRequestPasswordSetup(ctx, synced, token.accessToken, 'login'); } + const detachedOwnerId = await ctx.users.findDetachedKakaoOwner(profile.kakaoId); + const detachedOwner = detachedOwnerId ? await ctx.users.findById(detachedOwnerId) : null; + if ( + detachedOwnerId && + (!detachedOwner || (existingByEmail && existingByEmail.id !== detachedOwnerId)) + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이전 계정에 귀속된 카카오 계정입니다. 관리자에게 복구를 요청해 주세요.', + }); + } + const recoveryTarget = detachedOwner ?? existingByEmail; const joinOauthInfo = oauthInfoFromToken(token, tokenIssuedAt); - const recoveryIntent = existingByEmail + const recoveryIntent = recoveryTarget ? ('link_existing' as const) : alreadyRegisteredWithKakao ? ('rejoin' as const) @@ -524,7 +515,7 @@ export const appRouter = router({ const stored = await ctx.oauthSessions.createSession({ mode: pending.mode, intent: recoveryIntent, - targetUserId: existingByEmail?.id, + targetUserId: recoveryTarget?.id, kakaoId: profile.kakaoId, email: profile.email, accessToken: token.accessToken, @@ -540,6 +531,9 @@ export const appRouter = router({ action: recoveryIntent, oauthSessionId: stored.id, email: stored.email, + passwordRequired: recoveryTarget + ? recoveryTarget.email?.toLowerCase() !== profile.email + : false, }; } @@ -557,7 +551,7 @@ export const appRouter = router({ }) ) .mutation(async ({ ctx, input }) => { - const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); + const oauthSession = await ctx.oauthSessions.getSession(input.oauthSessionId); if (!oauthSession) { throw new TRPCError({ code: 'UNAUTHORIZED', @@ -571,6 +565,8 @@ export const appRouter = router({ }); } + if (!(await ctx.oauthSessions.consumeSession(input.oauthSessionId))) + throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 계정 확인이 이미 사용되었습니다.' }); if (input.action === 'rejoin') { const [oauthOwner, emailOwner] = await Promise.all([ ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId), @@ -611,7 +607,7 @@ export const appRouter = router({ ctx.users.findByEmail(oauthSession.email), ctx.users.findByOauthId('KAKAO', oauthSession.kakaoId), ]); - if (!targetUser || emailOwner?.id !== targetUser.id) { + if (!targetUser || (emailOwner && emailOwner.id !== targetUser.id)) { throw new TRPCError({ code: 'CONFLICT', message: @@ -626,46 +622,40 @@ export const appRouter = router({ } assertLoginAllowed(targetUser); - const oauthInfo: UserOAuthInfo = { - accessToken: oauthSession.accessToken, - refreshToken: oauthSession.refreshToken, - accessTokenValidUntil: oauthSession.accessTokenValidUntil, - refreshTokenValidUntil: oauthSession.refreshTokenValidUntil, - }; - let linked: UserRecord; - try { - const replacement = - targetUser.oauthType === 'KAKAO' && - Boolean(targetUser.oauthId) && - targetUser.oauthId !== oauthSession.kakaoId; - linked = replacement - ? await ctx.users.replaceKakaoWithApprovedIdentity(targetUser.id, { - oauthId: oauthSession.kakaoId, - email: oauthSession.email, - oauthInfo, - verifiedAt: new Date(), - }) - : await ctx.users.relinkKakaoByEmail(targetUser.id, { - oauthId: oauthSession.kakaoId, - email: oauthSession.email, - oauthInfo, - verifiedAt: new Date(), - }); - } catch (error) { + return beginPendingKakaoLink(ctx, targetUser, oauthSession); + }), + kakaoLinkWithPassword: procedure + .input(z.object({ oauthSessionId: z.string().uuid(), username: zUsername, credential: zPasswordEnvelope })) + .mutation(async ({ ctx, input }) => { + const oauth = await ctx.oauthSessions.getSession(input.oauthSessionId); + if (!oauth || !['register', 'rejoin', 'link_existing'].includes(oauth.intent ?? 'register')) throw new TRPCError({ - code: targetUser.oauthType === 'KAKAO' ? 'PRECONDITION_FAILED' : 'CONFLICT', - message: - targetUser.oauthType === 'KAKAO' - ? '기존 카카오 계정 교체에는 관리자 승인이 필요합니다.' - : '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', - cause: error, + code: 'UNAUTHORIZED', + message: '카카오 연결 확인이 만료되었습니다. 다시 로그인해 주세요.', }); - } - await ctx.flushPublisher.publishUserFlush( - linked.id, - targetUser.oauthType === 'KAKAO' ? 'kakao-account-replaced' : 'kakao-account-relinked' - ); - return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login'); + await validatePendingKakaoProfile(ctx, oauth); + const user = await ctx.users.findByUsername(input.username); + const password = openPassword(ctx.passwordEnvelope, input.credential); + if ( + !user || + !(await ctx.users.verifyPassword(user, password)) || + (oauth.targetUserId && oauth.targetUserId !== user.id) + ) + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '기존 계정명 또는 비밀번호가 일치하지 않습니다.', + }); + const emailOwner = await ctx.users.findByEmail(oauth.email); + if (emailOwner && emailOwner.id !== user.id) + throw new TRPCError({ + code: 'CONFLICT', + message: '새 카카오 이메일이 다른 계정에 연결되어 있습니다. 관리자에게 문의해 주세요.', + }); + if (!(await ctx.oauthSessions.consumeSession(oauth.id))) + throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 연결 확인이 이미 사용되었습니다.' }); + const refreshed = await ctx.users.findById(user.id); + if (!refreshed) throw new TRPCError({ code: 'UNAUTHORIZED' }); + return beginPendingKakaoLink(ctx, refreshed, oauth, true); }), kakaoSetPassword: procedure .input( @@ -676,6 +666,19 @@ export const appRouter = router({ ) .mutation(async ({ ctx, input }) => { const password = openPassword(ctx.passwordEnvelope, input.credential); + const staged = await ctx.oauthSessions.getSession(input.oauthSessionId); + if (staged?.intent === 'identity_link' && staged.identityLink && staged.targetUserId) { + await validatePendingKakaoProfile(ctx, staged); + const user = await ctx.users.findById(staged.targetUserId); + if (!user) throw new TRPCError({ code: 'UNAUTHORIZED' }); + if (!(await ctx.oauthSessions.consumeSession(staged.id))) + throw new TRPCError({ code: 'UNAUTHORIZED' }); + const next = await ctx.oauthSessions.createSession({ + ...staged, + identityLink: { ...staged.identityLink, credential: input.credential }, + }); + return requestPendingKakaoOtp(ctx, next, user); + } const oauthSession = await ctx.oauthSessions.consumeSession(input.oauthSessionId); if (!oauthSession || oauthSession.intent !== 'password_setup' || !oauthSession.targetUserId) { throw new TRPCError({ @@ -976,6 +979,13 @@ export const appRouter = router({ oauthSessions: ctx.oauthSessions, users: ctx.users, }).catch((error: unknown) => throwKakaoVerificationError(error)); + if (verified.pendingLinkSessionId) + return finishPendingKakaoLink( + ctx, + verified.pendingLinkSessionId, + verified.user.id, + verified.validUntil + ); return { ...(await completeGatewayLogin(ctx, verified.user, 'login')), validUntil: verified.validUntil, diff --git a/app/gateway-api/src/trpc.ts b/app/gateway-api/src/trpc.ts index 6dc912b0..bea9e64c 100644 --- a/app/gateway-api/src/trpc.ts +++ b/app/gateway-api/src/trpc.ts @@ -12,7 +12,11 @@ export const procedure = t.procedure.use(async ({ ctx, path, getRawInput, next } const raw: unknown = await getRawInput(); const input = raw && typeof raw === 'object' ? (raw as Record) : {}; const resolveSubject = async (): Promise => { - if (path === 'auth.login' && typeof input.username === 'string' && input.username.length <= 64) { + if ( + (path === 'auth.login' || path === 'auth.kakaoLinkWithPassword') && + typeof input.username === 'string' && + input.username.length <= 64 + ) { return `login:${input.username.trim().toLocaleLowerCase('en-US')}`; } if ( diff --git a/app/gateway-api/test/accountIdentity.integration.test.ts b/app/gateway-api/test/accountIdentity.integration.test.ts index 631f7401..433b814f 100644 --- a/app/gateway-api/test/accountIdentity.integration.test.ts +++ b/app/gateway-api/test/accountIdentity.integration.test.ts @@ -2,12 +2,15 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; +import { snapshotKakaoLink } from '../src/auth/kakaoLinkPolicy.js'; import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js'; const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; const integration = describe.skipIf(!databaseUrl); const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161'; const secondUserId = '8f610116-64bd-4f18-bf7f-eae1737b227c'; +const bootstrapId = '00000000-0000-4000-8000-000000000042'; +const selfUserId = '00000000-0000-4000-8000-000000000043'; const oldKakaoId = 'account-identity-old-kakao'; const newKakaoId = 'account-identity-new-kakao'; @@ -30,9 +33,34 @@ integration('account identity PostgreSQL transaction', () => { db = connector.prisma; closeDb = () => connector.disconnect(); await db.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } }); - await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } }); + await db.kakaoIdentityLinkHistory.deleteMany({ + where: { userId: { in: [firstUserId, secondUserId, selfUserId] } }, + }); + await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } }); await db.appUser.createMany({ data: [ + { + id: bootstrapId, + loginId: 'identity-bootstrap', + displayName: '복구통합관리자', + passwordHash: 'not-used', + passwordSalt: 'not-used', + roles: ['superuser'], + sanctions: {}, + createdAt: new Date('2000-01-01'), + }, + { + id: selfUserId, + loginId: 'identity-self', + displayName: '직접복구계정', + passwordHash: 'not-used', + passwordSalt: 'not-used', + roles: ['user'], + sanctions: {}, + oauthType: 'KAKAO', + oauthId: 'self-db-old', + email: 'self-db@example.test', + }, { id: firstUserId, loginId: 'identity-integration-one', @@ -63,7 +91,10 @@ integration('account identity PostgreSQL transaction', () => { afterAll(async () => { await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } }); - await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } }); + await db?.kakaoIdentityLinkHistory.deleteMany({ + where: { userId: { in: [firstUserId, secondUserId, selfUserId] } }, + }); + await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId, bootstrapId, selfUserId] } } }); await closeDb?.(); }); @@ -160,4 +191,93 @@ integration('account identity PostgreSQL transaction', () => { expect(restored?.oauthId).toBe(before?.oauthId); expect(restored?.sanctions).toEqual(before?.sanctions); }); + it('commits identity, imported password, proof and history together, and restricts former identities to their owner', async () => { + const users = createPostgresUserRepository(db); + await db.appUser.update({ where: { id: selfUserId }, data: { passwordResetRequired: true } }); + const before = await users.findById(selfUserId); + const now = new Date(); + const input = { + expected: snapshotKakaoLink(before!), + authorization: 'EMAIL' as const, + oauthId: 'self-db-new', + email: 'self-db@example.test', + oauthInfo: {}, + verifiedAt: now, + talkVerifiedUntil: new Date(now.getTime() + 86400000), + password: 'new-self-password', + }; + const restored = await users.completeKakaoIdentityLink(selfUserId, input); + expect(restored).toMatchObject({ + oauthId: 'self-db-new', + passwordResetRequired: false, + authRevision: (before?.authRevision ?? 0) + 1, + }); + expect(await users.verifyPassword(restored, 'new-self-password')).toBe(true); + expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId); + expect(await users.isKakaoIdentityRetired('self-db-old')).toBe(false); + expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(1); + await expect(users.completeKakaoIdentityLink(selfUserId, input)).rejects.toThrow(); + await expect( + users.createUser({ + username: 'identity-history-claim', + password: 'secretpass', + oauth: { type: 'KAKAO', id: 'self-db-old', email: 'claim@example.test', info: {} }, + }) + ).rejects.toThrow(); + const prior = await users.findById(selfUserId); + await users.completeKakaoIdentityLink(selfUserId, { + ...input, + expected: snapshotKakaoLink(prior!), + oauthId: 'self-db-old', + }); + expect((await users.findById(selfUserId))?.oauthId).toBe('self-db-old'); + const snapshot = await users.findById(selfUserId); + const conflicting = await users.findById(secondUserId); + await expect( + users.completeKakaoIdentityLink(selfUserId, { + ...input, + expected: snapshotKakaoLink(snapshot!), + oauthId: conflicting!.oauthId!, + password: 'must-not-be-saved', + }) + ).rejects.toThrow(); + expect((await users.findById(selfUserId))?.passwordHash).toBe(snapshot?.passwordHash); + expect(await db.kakaoIdentityLinkHistory.count({ where: { userId: selfUserId } })).toBe(2); + await expect( + users.markKakaoTalkVerified(selfUserId, new Date(Date.now() + 10000), 0, 'self-db-old') + ).rejects.toThrow(); + }); + + it('allows only one concurrent recovery against the same PostgreSQL snapshot', async () => { + const users = createPostgresUserRepository(db); + const user = await users.findById(selfUserId); + const input = { + expected: snapshotKakaoLink(user!), + authorization: 'EMAIL' as const, + email: 'self-db@example.test', + oauthInfo: {}, + verifiedAt: new Date(), + talkVerifiedUntil: new Date(Date.now() + 86400000), + }; + const results = await Promise.allSettled( + ['self-db-race-one', 'self-db-race-two'].map((oauthId) => + users.completeKakaoIdentityLink(selfUserId, { ...input, oauthId }) + ) + ); + expect(results.filter((r) => r.status === 'fulfilled')).toHaveLength(1); + expect(results.filter((r) => r.status === 'rejected')).toHaveLength(1); + expect((await users.findById(selfUserId))?.authRevision).toBe((user?.authRevision ?? 0) + 1); + }); + it('keeps former identity ownership after the service account is deleted', async () => { + const users = createPostgresUserRepository(db); + await users.deleteUser(selfUserId); + expect(await users.findDetachedKakaoOwner('self-db-old')).toBe(selfUserId); + await expect( + users.createUser({ + username: 'deleted-owner-claim', + password: 'secretpass', + oauth: { type: 'KAKAO', id: 'self-db-old', email: 'deleted-owner-claim@example.test', info: {} }, + }) + ).rejects.toThrow(); + }); }); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index dda44d13..923b6647 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -269,6 +269,7 @@ const buildCaller = ( flushPublisher, userIconUpload, kakaoProfile, + kakaoClient, sentTalkMessages, refreshTokenCalls, sealPassword, @@ -640,11 +641,17 @@ describe('gateway auth flow', () => { }); setSessionHeader(register.sessionToken); const start = await caller.auth.kakaoStart({ mode: 'verify' }); - const verified = await caller.auth.kakaoExchange({ + const choice = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state, }); + if (choice.status !== 'account_recovery') throw new Error('Expected link confirmation.'); + const verified = await caller.auth.kakaoLinkWithPassword({ + oauthSessionId: choice.oauthSessionId, + username: 'verify-user', + credential: sealPassword('verify-password'), + }); expect(verified.status).toBe('otp'); if (verified.status !== 'otp') { throw new Error('Expected Kakao OTP challenge.'); @@ -685,11 +692,6 @@ describe('gateway auth flow', () => { }); emailOwner.passwordResetRequired = true; await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); - await users.setKakaoReplacementApproval(emailOwner.id, { - until: new Date(Date.now() + 60_000), - approvedByUserId: 'admin-user-id', - reason: '이메일 보존 계정의 교체 승인', - }); kakaoProfile.id = 'different-kakao-id'; const start = await caller.auth.kakaoStart({ mode: 'login' }); @@ -715,15 +717,16 @@ describe('gateway auth flow', () => { }); expect(passwordSet.status).toBe('otp'); expect(sentTalkMessages).toHaveLength(1); + expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toMatchObject({ id: emailOwner.id }); + expect((await users.findById(emailOwner.id))?.passwordResetRequired).toBe(true); + if (passwordSet.status !== 'otp') throw new Error('Expected OTP.'); + const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; + await caller.auth.kakaoOtp({ challengeId: passwordSet.challengeId, code: code! }); expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); - expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true); - expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ - id: emailOwner.id, - username: 'email-owner', - email: 'tester@example.com', - }); - expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-replaced'); - expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed'); + expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(false); + expect(await users.findDetachedKakaoOwner('original-kakao-id')).toBe(emailOwner.id); + expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ id: emailOwner.id }); + expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); }); it('requires a one-time password setup before an imported Kakao account can receive a session', async () => { @@ -896,42 +899,41 @@ describe('gateway auth flow', () => { }); }); - it('replaces an approved Kakao identity, retires the old ID, and invalidates older sessions', async () => { - const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({ - kakaoId: 'new-kakao-id', - kakaoEmail: 'new-kakao@example.com', - }); - const user = await users.createUser({ + it('completes an approved administrator replacement only after fresh talk proof and keeps old-ID ownership', async () => { + const f = buildCaller({ kakaoId: 'new-kakao-id', kakaoEmail: 'new-kakao@example.com' }); + const user = await f.users.createUser({ username: 'approved-replacement-user', password: 'replacement-password', oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} }, }); - const oldSession = await sessions.createSession(user); - setSessionHeader(oldSession.sessionToken); - await users.setKakaoReplacementApproval(user.id, { - until: new Date(Date.now() + 60_000), - approvedByUserId: 'admin-user-id', - reason: '사용자 본인 확인 완료', + await f.users.updateRoles(user.id, ['admin']); + await f.users.setKakaoReplacementApproval(user.id, { + until: new Date(Date.now() + 60000), + approvedByUserId: 'operator', + reason: '본인 확인 완료', }); - - const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken }); - const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); - expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' }); - expect(await users.findById(user.id)).toMatchObject({ + const old = await f.sessions.createSession(user); + f.setSessionHeader(old.sessionToken); + const start = await f.caller.auth.kakaoStart({ mode: 'verify' }); + const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state }); + if (choice.status !== 'account_recovery') throw new Error('Expected confirmation.'); + const otp = await f.caller.auth.kakaoResolveAccount({ + oauthSessionId: choice.oauthSessionId, + action: 'link_existing', + }); + if (otp.status !== 'otp') throw new Error('Expected OTP.'); + expect((await f.users.findById(user.id))?.oauthId).toBe('old-kakao-id'); + expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).not.toBeNull(); + const code = f.sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; + await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: code! }); + expect(await f.users.findById(user.id)).toMatchObject({ oauthId: 'new-kakao-id', - email: 'new-kakao@example.com', - kakaoReplacementApprovedUntil: undefined, authRevision: 1, + kakaoReplacementApprovedUntil: undefined, }); - await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true); - await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull(); - - kakaoProfile.id = 'old-kakao-id'; - kakaoProfile.email = 'old-kakao@example.com'; - const retiredStart = await caller.auth.kakaoStart({ mode: 'login' }); - await expect( - caller.auth.kakaoExchange({ code: 'oauth-code', state: retiredStart.state }) - ).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('영구 폐기') }); + expect(await f.users.isKakaoIdentityRetired('old-kakao-id')).toBe(false); + expect(await f.users.findDetachedKakaoOwner('old-kakao-id')).toBe(user.id); + expect(await f.caller.auth.me({ sessionToken: old.sessionToken })).toBeNull(); }); it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => { @@ -1133,22 +1135,12 @@ describe('gateway auth flow', () => { }); it('blocks Kakao login while a ban is active', async () => { - const { caller, users, sealPassword, setSessionHeader } = buildCaller(); - const register = await caller.auth.registerLocal({ + const { caller, users } = buildCaller(); + await users.createUser({ username: 'kakao-banned-user', - credential: sealPassword('kakao-banned-password'), - displayName: '카카오제재유저', - termsAgreed: true, - privacyAgreed: true, - thirdPartyUse: false, + password: 'kakao-banned-password', + oauth: { type: 'KAKAO', id: '1', email: 'tester@example.com', info: {} }, }); - setSessionHeader(register.sessionToken); - const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' }); - await caller.auth.kakaoExchange({ - code: 'oauth-code', - state: verifyStart.state, - }); - const stored = await users.findByUsername('kakao-banned-user'); expect(stored).not.toBeNull(); if (stored) { @@ -1948,3 +1940,230 @@ describe('account deletion cancellation', () => { expect(canCancelDeletion({ ...legacy!, legacyMemberNo: undefined }, new Date('2026-11-07'))).toBe(false); }); }); + +describe('staged Kakao self recovery', () => { + const prepare = async ( + options: { differentEmail?: boolean; resetPassword?: boolean; privileged?: boolean } = {} + ) => { + const f = buildCaller({ + kakaoId: 'new-self-kakao', + kakaoEmail: options.differentEmail ? 'new@example.test' : 'owner@example.test', + }); + const user = await f.users.createUser({ + username: 'self-recover', + password: 'originalpass', + oauth: { type: 'KAKAO', id: 'old-self-kakao', email: 'owner@example.test', info: {} }, + }); + if (options.privileged) await f.users.updateRoles(user.id, ['admin']); + user.passwordResetRequired = options.resetPassword ?? false; + user.kakaoTalkVerifiedUntil = new Date(Date.now() + 86400000).toISOString(); + const old = await f.sessions.createSession(user); + const start = await f.caller.auth.kakaoStart({ mode: 'login' }); + const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state }); + return { ...f, user, old, choice }; + }; + const otpCode = (f: { sentTalkMessages: string[] }): string => + f.sentTalkMessages.at(-1)!.match(/인증 코드는 (\d{4})/)![1]!; + + it('recovers an ordinary matching-email account without approval and only commits after fresh OTP', async () => { + const f = await prepare(); + if (f.choice.status !== 'account_recovery') throw new Error('Expected confirmation.'); + const oldChallenge = await f.oauthSessions.createLoginChallenge({ + userId: f.user.id, + code: '1111', + attemptsRemaining: 3, + expiresAt: new Date(Date.now() + 180000).toISOString(), + createdAt: new Date().toISOString(), + authRevision: 0, + oauthId: 'old-self-kakao', + }); + const otp = await f.caller.auth.kakaoResolveAccount({ + oauthSessionId: f.choice.oauthSessionId, + action: 'link_existing', + }); + if (otp.status !== 'otp') throw new Error('Expected fresh OTP despite a cached proof.'); + expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao'); + expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull(); + expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull(); + await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: '0000' })).rejects.toBeDefined(); + expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao'); + expect((await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).status).toBe('login'); + expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).toBeNull(); + await expect(f.caller.auth.kakaoOtp({ challengeId: oldChallenge.id, code: '1111' })).rejects.toMatchObject({ + code: 'PRECONDITION_FAILED', + }); + expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBe(f.user.id); + expect(await f.users.isKakaoIdentityRetired('old-self-kakao')).toBe(false); + await expect(f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) })).rejects.toBeDefined(); + f.kakaoProfile.id = 'old-self-kakao'; + const start = await f.caller.auth.kakaoStart({ mode: 'login' }); + const choice = await f.caller.auth.kakaoExchange({ code: 'code', state: start.state }); + if (choice.status !== 'account_recovery') throw new Error('Expected same-owner recovery.'); + const back = await f.caller.auth.kakaoResolveAccount({ + oauthSessionId: choice.oauthSessionId, + action: 'link_existing', + }); + if (back.status !== 'otp') throw new Error('Expected OTP.'); + await f.caller.auth.kakaoOtp({ challengeId: back.challengeId, code: otpCode(f) }); + expect((await f.users.findById(f.user.id))?.oauthId).toBe('old-self-kakao'); + await expect( + f.users.createUser({ + username: 'claim-other', + password: 'secretpass', + oauth: { type: 'KAKAO', id: 'new-self-kakao', email: 'other@example.test', info: {} }, + }) + ).rejects.toThrow(); + }); + + it('accepts the old password for a different email, keeps retries possible and verifies new talk ownership', async () => { + const f = await prepare({ differentEmail: true }); + if (f.choice.status !== 'join') throw new Error('Expected no email match.'); + await expect( + f.caller.auth.kakaoLinkWithPassword({ + oauthSessionId: f.choice.oauthSessionId, + username: f.user.username, + credential: f.sealPassword('wrongpass'), + }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + const otp = await f.caller.auth.kakaoLinkWithPassword({ + oauthSessionId: f.choice.oauthSessionId, + username: f.user.username, + credential: f.sealPassword('originalpass'), + }); + if (otp.status !== 'otp') throw new Error('Expected OTP.'); + expect((await f.users.findById(f.user.id))?.email).toBe('owner@example.test'); + await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) }); + expect(await f.users.findById(f.user.id)).toMatchObject({ + oauthId: 'new-self-kakao', + email: 'new@example.test', + }); + }); + + it('keeps migrated passwords and old identities intact until the final OTP commit', async () => { + const f = await prepare({ resetPassword: true }); + if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.'); + const setup = await f.caller.auth.kakaoResolveAccount({ + oauthSessionId: f.choice.oauthSessionId, + action: 'link_existing', + }); + if (setup.status !== 'password_setup') throw new Error('Expected password setup.'); + const hash = f.user.passwordHash; + const otp = await f.caller.auth.kakaoSetPassword({ + oauthSessionId: setup.oauthSessionId, + credential: f.sealPassword('new-valid-pass'), + }); + if (otp.status !== 'otp') throw new Error('Expected OTP.'); + expect(f.user.passwordHash).toBe(hash); + expect(f.user.passwordResetRequired).toBe(true); + await f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) }); + expect(f.user.passwordResetRequired).toBe(false); + expect(await f.users.verifyPassword(f.user, 'new-valid-pass')).toBe(true); + }); + + it('still requires approval for privileged accounts even with the password', async () => { + const f = await prepare({ privileged: true }); + if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.'); + await expect( + f.caller.auth.kakaoLinkWithPassword({ + oauthSessionId: f.choice.oauthSessionId, + username: f.user.username, + credential: f.sealPassword('originalpass'), + }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' }); + expect(f.user.oauthId).toBe('old-self-kakao'); + }); + + it.each(['roles', 'password', 'sanction', 'identity', 'expired', 'provider'] as const)( + 'does not commit if %s changes during OTP', + async (change) => { + const f = await prepare(); + if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.'); + const otp = await f.caller.auth.kakaoResolveAccount({ + oauthSessionId: f.choice.oauthSessionId, + action: 'link_existing', + }); + if (otp.status !== 'otp') throw new Error('Expected OTP.'); + if (change === 'roles') await f.users.updateRoles(f.user.id, ['admin']); + if (change === 'password') await f.users.updatePassword(f.user.id, 'changed-elsewhere'); + if (change === 'sanction') + await f.users.updateSanctions(f.user.id, { + bannedUntil: new Date(Date.now() + 86400000).toISOString(), + }); + if (change === 'identity') f.user.authRevision = 1; + if (change === 'expired') { + const challenge = await f.oauthSessions.getLoginChallengeForUser(f.user.id); + const pending = await f.oauthSessions.getSession(challenge!.oauthSessionId!); + pending!.createdAt = new Date(Date.now() - 301000).toISOString(); + } + if (change === 'provider') f.kakaoProfile.id = 'another-kakao'; + await expect( + f.caller.auth.kakaoOtp({ challengeId: otp.challengeId, code: otpCode(f) }) + ).rejects.toBeDefined(); + expect(f.user.oauthId).toBe('old-self-kakao'); + expect(await f.users.findDetachedKakaoOwner('old-self-kakao')).toBeNull(); + } + ); + + it('does not change any identity when sending the new talk challenge fails', async () => { + const f = await prepare(); + if (f.choice.status !== 'account_recovery') throw new Error('Expected recovery.'); + vi.spyOn(f.kakaoClient, 'sendTalkMessage').mockRejectedValue(new Error('synthetic provider failure')); + await expect( + f.caller.auth.kakaoResolveAccount({ oauthSessionId: f.choice.oauthSessionId, action: 'link_existing' }) + ).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '카카오톡 인증 코드를 보내지 못했습니다.' }); + expect(f.user.oauthId).toBe('old-self-kakao'); + expect(await f.caller.auth.me({ sessionToken: f.old.sessionToken })).not.toBeNull(); + }); + + it('checks password-based recovery over real HTTP before issuing the new challenge', async () => { + const f = await prepare({ differentEmail: true }); + if (f.choice.status !== 'join') throw new Error('Expected join fallback.'); + const app = fastify(); + await app.register(fastifyTRPCPlugin, { + prefix: '/gateway/api/trpc', + trpcOptions: { router: appRouter, ...trpcJsonBodyHttpServerOptions, createContext: () => f.context }, + }); + try { + const address = await app.listen({ host: '127.0.0.1', port: 0 }); + const post = (name: string, input: object) => + fetch(`${address}/gateway/api/trpc/${name}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + expect( + ( + await post('auth.kakaoLinkWithPassword', { + oauthSessionId: f.choice.oauthSessionId, + username: f.user.username, + credential: f.sealPassword('incorrect'), + }) + ).status + ).toBe(401); + expect( + ( + await post('auth.kakaoLinkWithPassword', { + oauthSessionId: 'invalid', + username: f.user.username, + credential: f.sealPassword('originalpass'), + }) + ).status + ).toBe(400); + const response = await post('auth.kakaoLinkWithPassword', { + oauthSessionId: f.choice.oauthSessionId, + username: f.user.username, + credential: f.sealPassword('originalpass'), + }); + expect(response.status).toBe(200); + const data = (await response.json()) as { result: { data: { status: string; challengeId: string } } }; + expect(data.result.data.status).toBe('otp'); + expect(f.user.oauthId).toBe('old-self-kakao'); + expect( + (await post('auth.kakaoOtp', { challengeId: data.result.data.challengeId, code: otpCode(f) })).status + ).toBe(200); + expect(f.user.oauthId).toBe('new-self-kakao'); + } finally { + await app.close(); + } + }); +}); diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts index 4f7c785a..ed359cc1 100644 --- a/app/gateway-api/test/oauthSessionStore.test.ts +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -104,6 +104,34 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { } }); + it('preserves the exact pending OAuth session and old identity revision in a one-use challenge', async () => { + const challenge = await store.createLoginChallenge({ + userId: randomUUID(), + code: '1234', + attemptsRemaining: 3, + expiresAt: new Date(Date.now() + 180000).toISOString(), + createdAt: new Date().toISOString(), + oauthSessionId: randomUUID(), + authRevision: 7, + oauthId: 'identity-before-change', + }); + userIds.add(challenge.userId); + challengeIds.add(challenge.id); + const results = await Promise.all([ + store.verifyLoginChallenge(challenge.id, '1234'), + store.verifyLoginChallenge(challenge.id, '1234'), + ]); + expect(results.filter((r) => r.status === 'verified')).toEqual([ + { + status: 'verified', + userId: challenge.userId, + oauthSessionId: challenge.oauthSessionId, + authRevision: 7, + oauthId: 'identity-before-change', + }, + ]); + }); + it('atomically consumes a successful code once', async () => { const challenge = await createChallenge(); diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 67d2b016..fdccd99d 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,7 +38,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20261022000000_add_open_requests', + gatewaySchemaHead: '20261107000000_kakao_identity_link_history', gameSchemaHead: '20260916080000_index_play_audit_decision_month', }); }); diff --git a/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts b/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts index 25024599..444b0eea 100644 --- a/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts +++ b/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts @@ -7,7 +7,7 @@ const operationNames = (route: Route): string[] => { return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); }; -const installFixture = async (page: Page) => { +const installFixture = async (page: Page, approved = true) => { const requests: Array<{ operation: string; body: string }> = []; await page.addInitScript(() => { window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session'); @@ -33,7 +33,7 @@ const installFixture = async (page: Page) => { nextRetireAt: null, thirdPartyUse: false, deleteAfter: null, - kakaoReplacementApprovedUntil: new Date(Date.now() + 86_400_000).toISOString(), + kakaoReplacementApprovedUntil: approved ? new Date(Date.now() + 86_400_000).toISOString() : null, }); } if (operation === 'account.notifications.get') { @@ -63,7 +63,7 @@ const installFixture = async (page: Page) => { return requests; }; -test('starts an approved Kakao replacement with explicit permanent-retirement confirmation', async ({ +test('starts a Kakao replacement with confirmation before authenticating the new account', async ({ page, }, testInfo) => { const requests = await installFixture(page); @@ -88,7 +88,7 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co await button.click(); await expect(page).toHaveURL(/replacement=started/); - expect(confirmation).toContain('기존 카카오 계정은 영구 폐기'); + expect(confirmation).toContain('새 카카오톡 인증을 완료하면'); const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart'); expect(startRequest?.body).toContain('verify'); expect(startRequest?.body).toContain('kakao-replacement-session'); @@ -105,3 +105,13 @@ test('starts an approved Kakao replacement with explicit permanent-retirement co await writeFile(testInfo.outputPath('account-kakao-replacement-mobile-geometry.json'), JSON.stringify(geometry)); await page.screenshot({ path: testInfo.outputPath('account-kakao-replacement-mobile.png'), fullPage: true }); }); + +test('offers ordinary users account replacement without an administrator approval', async ({ page }) => { + const requests = await installFixture(page, false); + page.on('dialog', (dialog) => dialog.accept()); + await page.goto('account'); + await expect(page.getByText(/교체 승인 .*까지/)).toHaveCount(0); + await page.getByRole('button', { name: '새 카카오 계정으로 교체', exact: true }).click(); + await expect(page).toHaveURL(/replacement=started/); + expect(requests.some(({ operation }) => operation === 'auth.kakaoStart')).toBe(true); +}); diff --git a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts index 8be9ec85..88d329e2 100644 --- a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts +++ b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts @@ -1,4 +1,5 @@ import { generateKeyPairSync } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; import { expect, test, type Page, type Route } from '@playwright/test'; @@ -172,3 +173,174 @@ for (const viewport of [ expect(geometry.right).toBeLessThanOrEqual(viewport.width); }); } + +const installSelfRecoveryFixture = async (page: Page, byPassword: boolean) => { + const calls: string[] = []; + let loggedIn = false; + let passwordAttempts = 0; + let release: (() => void) | undefined; + await page.route('**/gateway/api/trpc/**', async (route) => { + const results = []; + for (const operation of operationNames(route)) { + calls.push(operation); + let data: unknown; + if (operation === 'me') + data = loggedIn + ? { + id: 'self-user', + username: 'self-user', + displayName: '복구 사용자', + roles: ['user'], + kakaoVerified: true, + } + : null; + else if (operation === 'lobby.notice') data = ''; + else if (operation === 'lobby.profiles') data = []; + else if (operation === 'auth.passwordKey') + data = { keyId: 'self-link-key', publicKeyPem, algorithm: 'RSA-OAEP-256' }; + else if (operation === 'auth.kakaoExchange') + data = byPassword + ? { + status: 'join', + oauthSessionId: '33333333-3333-4333-8333-333333333333', + email: 'new@example.test', + } + : { + status: 'account_recovery', + action: 'link_existing', + oauthSessionId: '33333333-3333-4333-8333-333333333333', + email: 'retained@example.test', + passwordRequired: false, + }; + else if (operation === 'auth.kakaoLinkWithPassword') { + const input = Object.values( + route.request().postDataJSON() as Record< + string, + { username: string; credential: { ciphertext: string } } + > + )[0]!; + expect(input.username).toBe('self-user'); + expect(input.credential.ciphertext).not.toBe('fixture-password'); + passwordAttempts++; + if (passwordAttempts === 1) { + results.push({ + error: { + message: '기존 계정명 또는 비밀번호가 일치하지 않습니다.', + code: -32001, + data: { code: 'UNAUTHORIZED', httpStatus: 401 }, + }, + }); + continue; + } + await new Promise((resolve) => { + release = resolve; + }); + data = { + status: 'otp', + successStatus: 'login', + challengeId: '44444444-4444-4444-8444-444444444444', + expiresAt: '2099-01-01T00:00:00Z', + attemptsRemaining: 3, + }; + } else if (operation === 'auth.kakaoResolveAccount') + data = { + status: 'otp', + successStatus: 'login', + challengeId: '44444444-4444-4444-8444-444444444444', + expiresAt: '2099-01-01T00:00:00Z', + attemptsRemaining: 3, + }; + else if (operation === 'auth.kakaoOtp') { + loggedIn = true; + data = { status: 'login', sessionToken: 'fixture-self-recovered', validUntil: '2099-01-01T00:00:00Z' }; + } else throw new Error(`Unhandled self recovery operation: ${operation}`); + results.push(response(data)); + } + await route.fulfill({ contentType: 'application/json', body: JSON.stringify(results) }); + }); + return { + calls, + release: () => { + expect(release).toBeDefined(); + release!(); + }, + }; +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + for (const byPassword of [false, true]) { + test(`self-recovers by ${byPassword ? 'existing password' : 'matching email'} and new talk OTP on ${viewport.name}`, async ({ + page, + }, testInfo) => { + await page.setViewportSize(viewport); + const f = await installSelfRecoveryFixture(page, byPassword); + await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state'); + if (byPassword) { + await page.getByText('기존 계정 비밀번호로 연결', { exact: true }).click(); + const form = page.getByRole('form', { name: '기존 비밀번호로 카카오 연결' }); + await form.getByLabel('기존 계정명').fill('self-user'); + await form.getByLabel('기존 비밀번호').fill('wrong-password'); + const button = form.getByRole('button', { name: '비밀번호 확인 후 연결' }); + await button.click(); + await expect(page.getByRole('alert')).toContainText('일치하지 않습니다.'); + await form.getByLabel('기존 비밀번호').fill('fixture-password'); + await page.evaluate(() => document.fonts.ready); + await button.hover(); + expect(await button.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(47, 77, 108)'); + await page.mouse.down(); + expect(await button.evaluate((el) => el.matches(':active'))).toBe(true); + await page.mouse.move(1, 1); + await page.mouse.up(); + await button.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(button).toBeFocused(); + const geometry = await form.evaluate((el) => { + const rect = el.getBoundingClientRect(); + return { + left: rect.left, + right: rect.right, + width: rect.width, + height: rect.height, + viewport: innerWidth, + fontSize: getComputedStyle(el).fontSize, + html: el.outerHTML, + overflow: document.documentElement.scrollWidth - innerWidth, + }; + }); + expect(geometry.overflow).toBe(0); + expect(geometry.right).toBeLessThanOrEqual(viewport.width); + await writeFile(testInfo.outputPath('self-link-geometry.json'), JSON.stringify(geometry, null, 2)); + await writeFile(testInfo.outputPath('self-link-panel.html'), geometry.html); + await testInfo.attach('self-link-geometry', { + body: JSON.stringify(geometry), + contentType: 'application/json', + }); + await page.screenshot({ path: testInfo.outputPath('self-link-password.png'), fullPage: true }); + await button.click(); + await expect(form.getByRole('button')).toBeDisabled(); + await expect.poll(() => f.calls.filter((call) => call === 'auth.kakaoLinkWithPassword').length).toBe(2); + f.release(); + } else { + await page.getByRole('button', { name: '기존 계정에 연결', exact: true }).click(); + } + const dialog = page.getByRole('dialog', { name: '인증 코드 필요' }); + await expect(dialog).toBeVisible(); + expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull(); + await page.screenshot({ path: testInfo.outputPath('self-link-before-commit.png'), fullPage: true }); + await dialog.locator('input').fill('1234'); + await dialog.getByRole('button', { name: '제출', exact: true }).click(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBe( + 'fixture-self-recovered' + ); + expect(f.calls).not.toContain('auth.register'); + expect(f.calls).not.toContain('admin.users.setKakaoReplacementApproval'); + await page.reload(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + }); + } +} diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index 4f7bb302..8e0f85ed 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -87,11 +87,6 @@ const currentProfile = computed(() => const notificationEventTypes = computed( () => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[] ); -const kakaoReplacementApproved = computed( - () => - Boolean(account.value?.kakaoReplacementApprovedUntil) && - new Date(account.value!.kakaoReplacementApprovedUntil!).getTime() > Date.now() -); const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token'); @@ -321,7 +316,7 @@ const changePassword = async (): Promise => { const startKakaoReplacement = async (): Promise => { if ( !window.confirm( - '새 카카오 계정 연결이 끝나면 기존 카카오 계정은 영구 폐기되어 다시 가입하거나 연결할 수 없습니다. 계속하시겠습니까?' + '새 카카오톡 인증을 완료하면 로그인에 사용할 카카오 계정이 바뀌고 기존 세션이 종료됩니다. 계속하시겠습니까?' ) ) { return; @@ -667,7 +662,7 @@ onBeforeUnmount(() => { · 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지 취소 -
+
@@ -358,6 +403,37 @@ onMounted(() => { {{ submitting ? '가입 중...' : '가입' }} + 돌아가기 { padding: 18px; } +.password-link-panel { + margin: 18px; +} +.password-link-panel summary { + cursor: pointer; + color: #6db9ff; +} +.password-link-panel p { + line-height: 1.6; +} + .recovery-panel { padding: 18px; text-align: center; diff --git a/packages/infra/prisma/gateway-migrations/20261107000000_kakao_identity_link_history/migration.sql b/packages/infra/prisma/gateway-migrations/20261107000000_kakao_identity_link_history/migration.sql new file mode 100644 index 00000000..d4982aa6 --- /dev/null +++ b/packages/infra/prisma/gateway-migrations/20261107000000_kakao_identity_link_history/migration.sql @@ -0,0 +1,11 @@ +CREATE TABLE "kakao_identity_link_history" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "previous_oauth_id" TEXT NOT NULL, + "next_oauth_id" TEXT NOT NULL, + "authorization" TEXT NOT NULL, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "kakao_identity_link_history_pkey" PRIMARY KEY ("id") +); +CREATE INDEX "kakao_identity_link_history_previous_oauth_id_created_at_idx" ON "kakao_identity_link_history"("previous_oauth_id", "created_at"); +CREATE INDEX "kakao_identity_link_history_user_id_created_at_idx" ON "kakao_identity_link_history"("user_id", "created_at"); diff --git a/packages/infra/prisma/gateway.prisma b/packages/infra/prisma/gateway.prisma index d563b2a2..8451d344 100644 --- a/packages/infra/prisma/gateway.prisma +++ b/packages/infra/prisma/gateway.prisma @@ -126,6 +126,20 @@ model AppUser { @@map("app_user") } +// 교체 이력을 보존하며 이전 ID는 같은 서비스 계정만 다시 연결할 수 있다. +model KakaoIdentityLinkHistory { + id String @id @default(uuid()) + userId String @map("user_id") + previousOauthId String @map("previous_oauth_id") + nextOauthId String @map("next_oauth_id") + authorization String + createdAt DateTime @default(now()) @map("created_at") + + @@index([previousOauthId, createdAt]) + @@index([userId, createdAt]) + @@map("kakao_identity_link_history") +} + model RetiredKakaoIdentity { id String @id @default(uuid()) oauthId String @unique @map("oauth_id") diff --git a/release-manifest.json b/release-manifest.json index e05c2b48..789d8bce 100644 --- a/release-manifest.json +++ b/release-manifest.json @@ -1,7 +1,7 @@ { "formatVersion": 1, "controllerProtocol": 2, - "gatewaySchemaHead": "20261022000000_add_open_requests", + "gatewaySchemaHead": "20261107000000_kakao_identity_link_history", "gameSchemaHead": "20260916080000_index_play_audit_decision_month", "components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"] }