diff --git a/app/gateway-api/src/auth/attemptBudget.ts b/app/gateway-api/src/auth/attemptBudget.ts index c88fe462..e869aadc 100644 --- a/app/gateway-api/src/auth/attemptBudget.ts +++ b/app/gateway-api/src/auth/attemptBudget.ts @@ -107,6 +107,7 @@ const EXPENSIVE_ACTIONS = new Set([ 'auth.register', 'auth.registerLocal', 'auth.login', + 'auth.cancelDeletion', 'auth.kakaoOtp', 'account.changePassword', 'account.scheduleDeletion', diff --git a/app/gateway-api/src/auth/deletionRecovery.ts b/app/gateway-api/src/auth/deletionRecovery.ts new file mode 100644 index 00000000..39b70498 --- /dev/null +++ b/app/gateway-api/src/auth/deletionRecovery.ts @@ -0,0 +1,49 @@ +import { TRPCError } from '@trpc/server'; +import { isLoginBanned } from '@sammo-ts/common/auth/sanctions'; + +import type { GatewayApiContext } from '../context.js'; +import { toPublicUser, type UserRecord } from './userRepository.js'; + +// 이전 서비스의 장기 보존 탈퇴 계정만 복구한다. 날짜는 한국 시간 기준이다. +export const LEGACY_DELETION_RECOVERY_CUTOFF = new Date('2026-07-01T00:00:00+09:00'); + +export const canCancelDeletion = (user: UserRecord, now = new Date()): boolean => { + if (!user.deleteAfter) return false; + const deadline = new Date(user.deleteAfter).getTime(); + return ( + Number.isFinite(deadline) && + (deadline > now.getTime() || + (Number.isSafeInteger(user.legacyMemberNo) && + (user.legacyMemberNo ?? 0) > 0 && + deadline - 30 * 24 * 60 * 60 * 1000 < LEGACY_DELETION_RECOVERY_CUTOFF.getTime())) + ); +}; + +export const assertLoginAllowed = (user: UserRecord): void => { + if (isLoginBanned(user.sanctions)) { + throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' }); + } + if (user.deleteAfter && !canCancelDeletion(user)) { + throw new TRPCError({ code: 'FORBIDDEN', message: '계정 보존 기간이 지나 탈퇴를 취소할 수 없습니다.' }); + } +}; + +export const completeGatewayLogin = async ( + ctx: GatewayApiContext, + user: UserRecord, + status: T +) => { + assertLoginAllowed(user); + if (user.deleteAfter) { + // 비밀번호와 필요한 Kakao/OTP 검증을 모두 마친 뒤에만, 접근 권한 없는 일회용 증명을 발급한다. + const recovery = await ctx.oauthSessions.createDeletionRecovery({ + userId: user.id, + deleteAfter: user.deleteAfter, + authRevision: user.authRevision ?? 0, + expiresAt: new Date(Date.now() + 300_000).toISOString(), + }); + return { status: 'deletion_pending' as const, recoveryId: recovery.id, deleteAfter: user.deleteAfter }; + } + const session = await ctx.sessions.createSession(user); + return { status, user: toPublicUser(user), sessionToken: session.sessionToken, issuedAt: session.issuedAt }; +}; diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index b2c89173..f56f36c9 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -558,6 +558,20 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass } throw new Error('User not found.'); }, + async cancelDeletion(userId: string, deleteAfter: string, authRevision: number): Promise { + for (const user of usersByName.values()) { + if ( + user.id === userId && + user.deleteAfter === deleteAfter && + (user.authRevision ?? 0) === authRevision + ) { + user.deleteAfter = undefined; + user.authRevision = authRevision + 1; + return true; + } + } + return false; + }, async deleteUser(userId: string): Promise { for (const [username, user] of usersByName.entries()) { if (user.id === userId) { diff --git a/app/gateway-api/src/auth/oauthSessionStore.ts b/app/gateway-api/src/auth/oauthSessionStore.ts index 2928ad77..b70a808d 100644 --- a/app/gateway-api/src/auth/oauthSessionStore.ts +++ b/app/gateway-api/src/auth/oauthSessionStore.ts @@ -3,6 +3,14 @@ import { parseJson } from '@sammo-ts/common'; export type OAuthMode = 'login' | 'change_pw' | 'verify'; +export interface DeletionRecovery { + id: string; + userId: string; + deleteAfter: string; + authRevision: number; + expiresAt: string; +} + export interface OAuthPendingState { state: string; mode: OAuthMode; @@ -41,6 +49,8 @@ export type KakaoLoginChallengeResult = | { status: 'expired' }; export interface OAuthSessionStore { + createDeletionRecovery(recovery: Omit): Promise; + consumeDeletionRecovery(id: string): Promise; createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise; consumePendingState(state: string): Promise; createSession(session: Omit): Promise; @@ -106,6 +116,20 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { private readonly prefix: string; private readonly ttlSeconds: number; + async createDeletionRecovery(recovery: Omit): Promise { + const stored = { ...recovery, id: randomUUID() }; + await this.client.set(`${this.prefix}:deletion-recovery:${stored.id}`, JSON.stringify(stored), { EX: 300 }); + return stored; + } + + async consumeDeletionRecovery(id: string): Promise { + const raw = await this.client.eval(consumeOnceScript, { + keys: [`${this.prefix}:deletion-recovery:${id}`], + arguments: [], + }); + return typeof raw === 'string' ? parseJson(raw) : null; + } + constructor(client: RedisClientLike, prefix: string, ttlSeconds: number) { this.client = client; this.prefix = prefix; @@ -225,6 +249,19 @@ export class RedisOAuthSessionStore implements OAuthSessionStore { // 테스트용 인메모리 OAuth 세션 저장소. export class InMemoryOAuthSessionStore implements OAuthSessionStore { + private readonly deletionRecoveries = new Map(); + + async createDeletionRecovery(recovery: Omit): Promise { + const stored = { ...recovery, id: randomUUID() }; + this.deletionRecoveries.set(stored.id, stored); + return stored; + } + + async consumeDeletionRecovery(id: string): Promise { + const recovery = this.deletionRecoveries.get(id) ?? null; + this.deletionRecoveries.delete(id); + return recovery && new Date(recovery.expiresAt).getTime() > Date.now() ? recovery : null; + } private readonly pendingStates = new Map(); private readonly sessions = new Map(); private readonly loginChallenges = new Map(); diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index cdd29658..d781fb76 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -789,6 +789,14 @@ export const createPostgresUserRepository = ( data: { deleteAfter }, }); }, + async cancelDeletion(userId: string, deleteAfter: string, authRevision: number): Promise { + // 인증한 탈퇴 예약만 취소하고, 이전 session과 복구 증명은 revision으로 무효화한다. + const result = await prisma.appUser.updateMany({ + where: { id: userId, deleteAfter: new Date(deleteAfter), authRevision }, + data: { deleteAfter: null, authRevision: { increment: 1 } }, + }); + return result.count === 1; + }, async deleteUser(userId: string): Promise { await prisma.appUser.delete({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 726aab9e..721247c2 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -249,6 +249,7 @@ export interface UserRepository { resetProfileIcon(userId: string, requestedAt: Date): Promise; setThirdPartyUse(userId: string, allowed: boolean): Promise; scheduleDeletion(userId: string, deleteAfter: Date): Promise; + cancelDeletion(userId: string, deleteAfter: string, authRevision: number): Promise; deleteUser(userId: string): Promise; } diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 9038bb3b..47fbb9b8 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -23,6 +23,7 @@ import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } 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 { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; import type { GatewayApiContext } from './context.js'; import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; @@ -73,6 +74,7 @@ const finishKakaoLogin = async ( accessToken: string, successStatus: T ) => { + assertLoginAllowed(user); try { const challenge = await requireKakaoTalkProof({ user, @@ -87,13 +89,7 @@ const finishKakaoLogin = async ( } catch (error) { throwKakaoVerificationError(error); } - const session = await ctx.sessions.createSession(user); - return { - status: successStatus, - user: toPublicUser(user), - sessionToken: session.sessionToken, - issuedAt: session.issuedAt, - }; + return completeGatewayLogin(ctx, user, successStatus); }; const finishKakaoLoginOrRequestPasswordSetup = async ( @@ -102,6 +98,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async { + assertLoginAllowed(user); if (!user.passwordResetRequired) { return finishKakaoLogin(ctx, user, accessToken, successStatus); } @@ -627,18 +624,7 @@ export const appRouter = router({ message: '이미 다른 계정에 연결된 카카오 계정입니다.', }); } - if (targetUser.deleteAfter) { - throw new TRPCError({ - code: 'PRECONDITION_FAILED', - message: '탈퇴 처리 중인 계정에는 카카오 계정을 다시 연결할 수 없습니다.', - }); - } - if (isLoginBanned(targetUser.sanctions)) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Account login is blocked.', - }); - } + assertLoginAllowed(targetUser); const oauthInfo: UserOAuthInfo = { accessToken: oauthSession.accessToken, @@ -710,12 +696,7 @@ export const appRouter = router({ message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', }); } - if (user.deleteAfter) { - throw new TRPCError({ code: 'FORBIDDEN', message: 'Account deletion is pending.' }); - } - if (isLoginBanned(user.sanctions)) { - throw new TRPCError({ code: 'FORBIDDEN', message: 'Account login is blocked.' }); - } + assertLoginAllowed(user); let verifiedProfile; try { verifiedProfile = readVerifiedKakaoProfile(await ctx.kakaoClient.getMe(oauthSession.accessToken)); @@ -931,12 +912,6 @@ export const appRouter = router({ message: 'Invalid username or password.', }); } - if (user.deleteAfter) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Account deletion is pending.', - }); - } const password = openPassword(ctx.passwordEnvelope, input.credential); const ok = await ctx.users.verifyPassword(user, password); if (!ok) { @@ -945,22 +920,11 @@ export const appRouter = router({ message: 'Invalid username or password.', }); } - if (isLoginBanned(user.sanctions)) { - throw new TRPCError({ - code: 'FORBIDDEN', - message: 'Account login is blocked.', - }); - } + assertLoginAllowed(user); if (user.oauthType === 'KAKAO') { const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id); if (hasOperatorSpecialAccess(user) || hasActiveSpecialAccountGrant(specialAccessGrants)) { - const session = await ctx.sessions.createSession(user); - return { - status: 'login' as const, - user: toPublicUser(user), - sessionToken: session.sessionToken, - issuedAt: session.issuedAt, - }; + return completeGatewayLogin(ctx, user, 'login'); } const ready = await verifyStoredKakaoIdentity({ user, @@ -969,13 +933,34 @@ export const appRouter = router({ }).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, - }; + return completeGatewayLogin(ctx, user, 'login'); + }), + cancelDeletion: procedure + .input(z.object({ recoveryId: z.string().uuid() }).strict()) + .mutation(async ({ ctx, input }) => { + const recovery = await ctx.oauthSessions.consumeDeletionRecovery(input.recoveryId); + if (!recovery || new Date(recovery.expiresAt).getTime() <= Date.now()) { + throw new TRPCError({ + code: 'UNAUTHORIZED', + message: '복구 인증이 만료되었습니다. 다시 로그인해 주세요.', + }); + } + const user = await ctx.users.findById(recovery.userId); + if (!user) throw new TRPCError({ code: 'UNAUTHORIZED', message: '계정을 찾지 못했습니다.' }); + assertLoginAllowed(user); + if ( + !canCancelDeletion(user) || + !(await ctx.users.cancelDeletion(user.id, recovery.deleteAfter, recovery.authRevision)) + ) { + throw new TRPCError({ + code: 'CONFLICT', + message: '계정 상태가 변경되었습니다. 다시 로그인해 주세요.', + }); + } + await ctx.flushPublisher.publishUserFlush(user.id, 'account-deletion-cancelled'); + const restored = await ctx.users.findById(user.id); + if (!restored) throw new TRPCError({ code: 'UNAUTHORIZED', message: '계정을 찾지 못했습니다.' }); + return completeGatewayLogin(ctx, restored, 'login'); }), kakaoOtp: procedure .input( @@ -991,24 +976,8 @@ export const appRouter = router({ 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, + ...(await completeGatewayLogin(ctx, verified.user, 'login')), validUntil: verified.validUntil, }; }), diff --git a/app/gateway-api/test/accountIdentity.integration.test.ts b/app/gateway-api/test/accountIdentity.integration.test.ts index c6416ccf..631f7401 100644 --- a/app/gateway-api/test/accountIdentity.integration.test.ts +++ b/app/gateway-api/test/accountIdentity.integration.test.ts @@ -139,4 +139,25 @@ integration('account identity PostgreSQL transaction', () => { }) ).rejects.toThrow(); }); + it('cancels only the authenticated deletion snapshot once and persists the new auth revision', async () => { + const users = createPostgresUserRepository(db); + const deadline = new Date(Date.now() + 86400000); + await users.scheduleDeletion(firstUserId, deadline); + const before = await users.findById(firstUserId); + const revision = before?.authRevision ?? 0; + expect(await users.cancelDeletion(firstUserId, new Date(deadline.getTime() - 1).toISOString(), revision)).toBe( + false + ); + expect(await users.cancelDeletion(firstUserId, deadline.toISOString(), revision + 1)).toBe(false); + const results = await Promise.all([ + users.cancelDeletion(firstUserId, deadline.toISOString(), revision), + users.cancelDeletion(firstUserId, deadline.toISOString(), revision), + ]); + expect(results.filter(Boolean)).toHaveLength(1); + const restored = await users.findById(firstUserId); + expect(restored?.deleteAfter).toBeUndefined(); + expect(restored?.authRevision).toBe(revision + 1); + expect(restored?.oauthId).toBe(before?.oauthId); + expect(restored?.sanctions).toEqual(before?.sanctions); + }); }); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 91666fb8..dda44d13 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -1,4 +1,8 @@ import { describe, expect, it, vi } from 'vitest'; +import fastify from 'fastify'; +import { fastifyTRPCPlugin } from '@trpc/server/adapters/fastify'; +import { trpcJsonBodyHttpServerOptions } from '@sammo-ts/common'; +import { canCancelDeletion } from '../src/auth/deletionRecovery.js'; import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -228,36 +232,36 @@ const buildCaller = ( ).toString('base64'), }; }; - const caller = appRouter.createCaller( - createGatewayApiContext({ - users, - sessions, - flushPublisher, - gameTokenSecret: 'test-secret', - gameSessionTtlSeconds: 600, - kakaoClient: kakaoClient as unknown as KakaoOAuthClient, - oauthSessions, - publicBaseUrl: 'http://localhost', - userIconDir: options.userIconDir, - userIconPublicUrl: 'http://localhost/user-icons', - sharedIconPublicUrl: 'https://sam-image.hided.net/icons', - userIconUpload, - adminLocalAccountEnabled: false, - localRegistrationEnabled: true, - localAccountGraceDays: options.localAccountGraceDays ?? 7, - passwordEnvelope, - profiles, - orchestrator, - profileStatus, - requestHeaders, - prisma: { - appUser: { - findFirst: async () => null, - }, - } as unknown as GatewayPrismaClient, - }) - ); + const context = createGatewayApiContext({ + users, + sessions, + flushPublisher, + gameTokenSecret: 'test-secret', + gameSessionTtlSeconds: 600, + kakaoClient: kakaoClient as unknown as KakaoOAuthClient, + oauthSessions, + publicBaseUrl: 'http://localhost', + userIconDir: options.userIconDir, + userIconPublicUrl: 'http://localhost/user-icons', + sharedIconPublicUrl: 'https://sam-image.hided.net/icons', + userIconUpload, + adminLocalAccountEnabled: false, + localRegistrationEnabled: true, + localAccountGraceDays: options.localAccountGraceDays ?? 7, + passwordEnvelope, + profiles, + orchestrator, + profileStatus, + requestHeaders, + prisma: { + appUser: { + findFirst: async () => null, + }, + } as unknown as GatewayPrismaClient, + }); + const caller = appRouter.createCaller(context); return { + context, caller, oauthSessions, users, @@ -648,6 +652,7 @@ describe('gateway auth flow', () => { const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; expect(code).toBeTruthy(); const completed = await caller.auth.kakaoOtp({ challengeId: verified.challengeId, code: code! }); + if (completed.status !== 'login') throw new Error('Expected login.'); expect(completed.user.kakaoVerified).toBe(true); const stored = await users.findByUsername('verify-user'); expect(stored).toMatchObject({ @@ -1223,6 +1228,7 @@ describe('gateway auth flow', () => { const code = sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; expect(code).toBeTruthy(); const completed = await caller.auth.kakaoOtp({ challengeId: register.challengeId, code: code! }); + if (completed.status !== 'login') throw new Error('Expected login.'); expect(completed.user.username).toBe('tester'); expect(completed.sessionToken).toBeTruthy(); @@ -1785,3 +1791,160 @@ describe('account self service', () => { } }); }); + +describe('account deletion cancellation', () => { + const pendingLogin = async () => { + const f = buildCaller(); + const user = await f.users.createUser({ username: 'recover-user', password: 'secretpass' }); + const other = await f.users.createUser({ username: 'other-recover-user', password: 'secretpass' }); + const oldSession = await f.sessions.createSession(user); + const deadline = new Date(Date.now() + 30 * 86400000); + await f.caller.account.scheduleDeletion({ + sessionToken: oldSession.sessionToken, + currentCredential: f.sealPassword('secretpass'), + }); + const result = await f.caller.auth.login({ username: user.username, credential: f.sealPassword('secretpass') }); + if (result.status !== 'deletion_pending') throw new Error('Expected deletion confirmation.'); + return { ...f, user, other, oldSession, deadline, result }; + }; + + it('authenticates before offering cancellation and restores only the proved account once', async () => { + const f = await pendingLogin(); + await expect( + f.caller.auth.login({ username: f.user.username, credential: f.sealPassword('wrongpass') }) + ).rejects.toMatchObject({ code: 'UNAUTHORIZED' }); + expect(f.result).not.toHaveProperty('sessionToken'); + expect(await f.sessions.getSession(f.oldSession.sessionToken)).toBeNull(); + const restored = await f.caller.auth.cancelDeletion({ recoveryId: f.result.recoveryId }); + expect(restored.status).toBe('login'); + if (restored.status !== 'login') throw new Error('Expected login.'); + expect((await f.users.findById(f.user.id))?.deleteAfter).toBeUndefined(); + expect((await f.users.findById(f.user.id))?.authRevision).toBe(1); + expect((await f.sessions.getSession(restored.sessionToken))?.userId).toBe(f.user.id); + expect((await f.users.findById(f.other.id))?.authRevision).toBe(0); + expect(f.flushPublisher.publishUserFlush).toHaveBeenCalledWith(f.user.id, 'account-deletion-cancelled'); + await expect(f.caller.auth.cancelDeletion({ recoveryId: f.result.recoveryId })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + + it.each(['rescheduled', 'expired-proof', 'sanctioned', 'deleted'] as const)( + 'rejects a %s recovery without clearing deletion', + async (kind) => { + const f = await pendingLogin(); + if (kind === 'rescheduled') await f.users.scheduleDeletion(f.user.id, new Date(Date.now() + 60 * 86400000)); + if (kind === 'sanctioned') + await f.users.updateSanctions(f.user.id, { + bannedUntil: new Date(Date.now() + 86400000).toISOString(), + }); + if (kind === 'deleted') await f.users.deleteUser(f.user.id); + if (kind === 'expired-proof') vi.spyOn(Date, 'now').mockReturnValue(Date.now() + 301000); + try { + await expect(f.caller.auth.cancelDeletion({ recoveryId: f.result.recoveryId })).rejects.toBeDefined(); + } finally { + vi.restoreAllMocks(); + } + if (kind !== 'deleted') expect((await f.users.findById(f.user.id))?.deleteAfter).toBeDefined(); + expect(f.flushPublisher.publishUserFlush).not.toHaveBeenCalledWith(f.user.id, 'account-deletion-cancelled'); + } + ); + + it('requires a proof, rejects actor injection and verifies actual HTTP transport', async () => { + const f = await pendingLogin(); + 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 = (input: object) => + fetch(`${address}/gateway/api/trpc/auth.cancelDeletion`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(input), + }); + expect((await post({})).status).toBe(400); + expect((await post({ recoveryId: 'not-a-uuid' })).status).toBe(400); + expect((await post({ recoveryId: '11111111-1111-4111-8111-111111111111' })).status).toBe(401); + expect((await post({ recoveryId: f.result.recoveryId, userId: f.other.id })).status).toBe(400); + expect((await post({ recoveryId: f.result.recoveryId })).status).toBe(200); + expect((await post({ recoveryId: f.result.recoveryId })).status).toBe(401); + } finally { + await app.close(); + } + }); + + it.each([ + [false, false], + [true, false], + [false, true], + [true, true], + ])( + 'supports pending Kakao accounts after existing-email linking=%s, legacy=%s and OTP proof', + async (linkByEmail, legacy) => { + const f = buildCaller(); + const user = await f.users.createUser({ + username: 'kakao-recover', + password: 'secretpass', + oauth: { type: 'KAKAO', id: linkByEmail ? 'former-id' : '1', email: 'tester@example.com', info: {} }, + }); + if (linkByEmail) + await f.users.setKakaoReplacementApproval(user.id, { + until: new Date(Date.now() + 60000), + approvedByUserId: 'operator', + reason: '본인 확인 완료', + }); + if (legacy) { + user.legacyMemberNo = 42; + user.passwordResetRequired = true; + } + await f.users.scheduleDeletion( + user.id, + legacy ? new Date('2026-07-30T14:59:59.999Z') : new Date(Date.now() + 86400000) + ); + const start = await f.caller.auth.kakaoStart({ mode: 'login' }); + let result = await f.caller.auth.kakaoExchange({ code: 'test-code', state: start.state }); + if (linkByEmail) { + if (result.status !== 'account_recovery') throw new Error('Expected account link confirmation.'); + result = await f.caller.auth.kakaoResolveAccount({ + oauthSessionId: result.oauthSessionId, + action: 'link_existing', + }); + } + if (legacy) { + if (result.status !== 'password_setup') throw new Error('Expected migrated password setup.'); + result = await f.caller.auth.kakaoSetPassword({ + oauthSessionId: result.oauthSessionId, + credential: f.sealPassword('restoredpass'), + }); + } + if (result.status !== 'otp') throw new Error('Expected OTP proof.'); + expect((await f.users.findById(user.id))?.deleteAfter).toBeDefined(); + const code = f.sentTalkMessages.at(-1)?.match(/인증 코드는 (\d{4})/)?.[1]; + const verified = await f.caller.auth.kakaoOtp({ challengeId: result.challengeId, code: code! }); + if (verified.status !== 'deletion_pending') throw new Error('Expected deletion confirmation.'); + expect(verified).not.toHaveProperty('sessionToken'); + expect((await f.users.findById(user.id))?.deleteAfter).toBeDefined(); + expect((await f.caller.auth.cancelDeletion({ recoveryId: verified.recoveryId })).status).toBe('login'); + } + ); + + it('applies the pre-July legacy request exception at the exact Korean-time boundary', async () => { + const f = buildCaller(); + const user = await f.users.createUser({ username: 'legacy-recover', password: 'secretpass' }); + user.legacyMemberNo = 42; + const cutoffDeadline = new Date('2026-07-31T00:00:00+09:00'); + await f.users.scheduleDeletion(user.id, new Date(cutoffDeadline.getTime() - 1)); + const legacy = await f.users.findById(user.id); + expect(canCancelDeletion(legacy!, new Date('2026-11-07'))).toBe(true); + const result = await f.caller.auth.login({ username: user.username, credential: f.sealPassword('secretpass') }); + if (result.status !== 'deletion_pending') throw new Error('Expected legacy restoration.'); + await f.caller.auth.cancelDeletion({ recoveryId: result.recoveryId }); + await f.users.scheduleDeletion(user.id, cutoffDeadline); + await expect( + f.caller.auth.login({ username: user.username, credential: f.sealPassword('secretpass') }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect(canCancelDeletion({ ...legacy!, legacyMemberNo: undefined }, new Date('2026-11-07'))).toBe(false); + }); +}); diff --git a/app/gateway-api/test/oauthSessionStore.test.ts b/app/gateway-api/test/oauthSessionStore.test.ts index e9fa2b65..4f7c785a 100644 --- a/app/gateway-api/test/oauthSessionStore.test.ts +++ b/app/gateway-api/test/oauthSessionStore.test.ts @@ -82,6 +82,28 @@ describe.skipIf(!redisUrl)('RedisOAuthSessionStore Kakao state', () => { }); }); + it('consumes an isolated deletion proof once with a five-minute TTL', async () => { + const recovery = await store.createDeletionRecovery({ + userId: randomUUID(), + deleteAfter: new Date().toISOString(), + authRevision: 2, + expiresAt: new Date(Date.now() + 300000).toISOString(), + }); + const key = `${prefix}:deletion-recovery:${recovery.id}`; + try { + expect(await client.ttl(key)).toBeGreaterThan(0); + expect(await client.ttl(key)).toBeLessThanOrEqual(300); + const results = await Promise.all([ + store.consumeDeletionRecovery(recovery.id), + store.consumeDeletionRecovery(recovery.id), + ]); + expect(results.filter(Boolean)).toEqual([recovery]); + expect(await store.consumeSession(recovery.id)).toBeNull(); + } finally { + await client.del(key); + } + }); + it('atomically consumes a successful code once', async () => { const challenge = await createChallenge(); diff --git a/app/gateway-frontend/e2e/account-deletion-recovery.spec.ts b/app/gateway-frontend/e2e/account-deletion-recovery.spec.ts new file mode 100644 index 00000000..2be061cd --- /dev/null +++ b/app/gateway-frontend/e2e/account-deletion-recovery.spec.ts @@ -0,0 +1,186 @@ +import { generateKeyPairSync } from 'node:crypto'; +import { writeFile } from 'node:fs/promises'; +import { expect, test, type Page } from '@playwright/test'; + +const { publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 }); +const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString(); +const pending = { + status: 'deletion_pending', + recoveryId: '11111111-1111-4111-8111-111111111111', + deleteAfter: '2026-07-30T14:59:59.999Z', +}; +const otp = { + status: 'otp', + successStatus: 'login', + challengeId: '22222222-2222-4222-8222-222222222222', + expiresAt: '2026-11-07T12:00:00Z', + attemptsRemaining: 3, +}; +const installFixture = async (page: Page, entry: 'password' | 'kakao' | 'kakao-link', fail = false) => { + const calls: string[] = []; + let restored = false; + let complete: (() => void) | undefined; + await page.route('**/gateway/api/trpc/**', async (route) => { + const url = new URL(route.request().url()); + const names = decodeURIComponent(url.pathname.split('/trpc/')[1]!).split(','); + const results = []; + for (const name of names) { + calls.push(name); + let data: unknown; + if (name === 'me') + data = restored + ? { + id: 'fixture-user', + username: 'fixture-user', + displayName: '복구계정', + roles: ['user'], + kakaoVerified: true, + } + : null; + else if (name === 'lobby.profiles') data = []; + else if (name === 'lobby.notice') data = ''; + else if (name === 'auth.passwordKey') + data = { keyId: 'fixture-key', publicKeyPem, algorithm: 'RSA-OAEP-256' }; + else if (name === 'auth.login') data = pending; + else if (name === 'auth.kakaoExchange') + data = + entry === 'kakao-link' + ? { + status: 'account_recovery', + action: 'link_existing', + oauthSessionId: 'fixture-link-session', + email: 'retained@example.test', + } + : pending; + else if (name === 'auth.kakaoResolveAccount') data = otp; + else if (name === 'auth.kakaoOtp') data = { ...pending, validUntil: '2026-11-08T12:00:00Z' }; + else if (name === 'auth.cancelDeletion') { + const input = route.request().postDataJSON() as Record; + expect(Object.values(input)).toEqual([{ recoveryId: pending.recoveryId }]); + await new Promise((resolve) => { + complete = resolve; + }); + if (fail) { + results.push({ + error: { + message: '복구 인증이 만료되었습니다. 다시 로그인해 주세요.', + code: -32001, + data: { code: 'UNAUTHORIZED', httpStatus: 401 }, + }, + }); + continue; + } + restored = true; + data = { + status: 'login', + sessionToken: 'fixture-restored-session', + user: { id: 'fixture-user' }, + issuedAt: '2026-11-07T12:00:00Z', + }; + } else throw new Error(`Unhandled recovery operation: ${name}`); + results.push({ result: { data } }); + } + await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(results) }); + }); + return { + calls, + complete: () => { + expect(complete).toBeDefined(); + complete!(); + }, + }; +}; + +for (const viewport of [ + { name: 'desktop', width: 1200, height: 900 }, + { name: 'mobile', width: 390, height: 844 }, +]) { + for (const entry of ['password', 'kakao', 'kakao-link'] as const) { + test(`restores a pending account through ${entry} on ${viewport.name}`, async ({ page }, testInfo) => { + await page.setViewportSize(viewport); + const f = await installFixture(page, entry); + await page.goto( + entry === 'password' ? '/gateway/' : '/gateway/oauth/callback?code=fixture-code&state=fixture-state' + ); + if (entry === 'password') { + await page.getByLabel('계정명', { exact: true }).fill('fixture-user'); + await page.getByLabel('비밀번호', { exact: true }).fill('fixture-password'); + await page.getByRole('button', { name: '로그인', exact: true }).click(); + } + if (entry === 'kakao-link') { + await expect(page.getByRole('group', { name: '카카오 계정 연결 확인' })).toBeVisible(); + await page.getByRole('button', { name: '기존 계정에 연결' }).click(); + const dialog = page.getByRole('dialog', { name: '인증 코드 필요' }); + await expect(dialog).toBeVisible(); + await dialog.locator('input').fill('1234'); + await dialog.getByRole('button', { name: '제출', exact: true }).click(); + } + const panel = page.getByRole('group', { name: '탈퇴 취소', exact: true }); + await expect(panel).toBeVisible(); + expect(f.calls).not.toContain('auth.cancelDeletion'); + expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull(); + await page.evaluate(() => document.fonts.ready); + const geometry = await panel.evaluate((element) => { + const rect = element.getBoundingClientRect(); + const style = getComputedStyle(element); + return { + x: rect.x, + width: rect.width, + height: rect.height, + textAlign: style.textAlign, + fontSize: style.fontSize, + html: element.outerHTML, + }; + }); + expect(geometry.x).toBeGreaterThanOrEqual(0); + expect(geometry.width).toBeLessThan(viewport.width); + expect(geometry.textAlign).toBe('center'); + expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(viewport.width); + const button = panel.getByRole('button', { name: '탈퇴 취소', exact: true }); + 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(0, 0); + await page.mouse.up(); + await button.focus(); + await page.keyboard.press('Tab'); + await page.keyboard.press('Shift+Tab'); + await expect(button).toBeFocused(); + expect(await button.evaluate((el) => getComputedStyle(el).outlineStyle)).toBe('solid'); + await writeFile(testInfo.outputPath('panel-dom-geometry-style.json'), JSON.stringify(geometry, null, 2)); + await writeFile(testInfo.outputPath('panel.html'), geometry.html); + await testInfo.attach('panel-dom-geometry-style', { + body: JSON.stringify(geometry, null, 2), + contentType: 'application/json', + }); + await page.screenshot({ path: testInfo.outputPath('deletion-confirmation.png'), fullPage: true }); + await button.click(); + await expect(panel.getByRole('button', { name: '처리 중...' })).toBeDisabled(); + await expect(panel.getByRole('button', { name: '돌아가기' })).toBeDisabled(); + await expect.poll(() => f.calls.filter((call) => call === 'auth.cancelDeletion').length).toBe(1); + f.complete(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBe( + 'fixture-restored-session' + ); + await page.reload(); + await expect(page).toHaveURL(/\/gateway\/lobby$/); + }); + } +} + +test('keeps deletion unchanged when leaving confirmation or when recovery expires', async ({ page }) => { + const f = await installFixture(page, 'kakao', true); + await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state'); + const panel = page.getByRole('group', { name: '탈퇴 취소', exact: true }); + await panel.getByRole('button', { name: '돌아가기' }).click(); + await expect(page).toHaveURL(/\/gateway\/$/); + expect(f.calls).not.toContain('auth.cancelDeletion'); + await page.goto('/gateway/oauth/callback?code=fixture-code&state=fixture-state'); + await panel.getByRole('button', { name: '탈퇴 취소' }).click(); + await expect.poll(() => f.calls.filter((call) => call === 'auth.cancelDeletion').length).toBe(1); + f.complete(); + await expect(panel.getByRole('alert')).toContainText('다시 로그인'); + expect(await page.evaluate(() => localStorage.getItem('sammo-session-token'))).toBeNull(); +}); diff --git a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts index 0c0325c6..8be9ec85 100644 --- a/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts +++ b/app/gateway-frontend/e2e/kakao-account-recovery.spec.ts @@ -98,6 +98,7 @@ const verifyRecoveryChoice = async (page: Page, action: 'link_existing' | 'rejoi await expect(group).toContainText( action === 'link_existing' ? '이 계정에 카카오 로그인을 연결해드릴까요?' : '새 계정으로 재가입하시겠습니까?' ); + await page.evaluate(() => document.fonts.ready); const geometry = await group.evaluate((element) => { const rect = element.getBoundingClientRect(); const style = getComputedStyle(element); diff --git a/app/gateway-frontend/e2e/kakao-otp.spec.ts b/app/gateway-frontend/e2e/kakao-otp.spec.ts index bd6f67ad..a44ebe90 100644 --- a/app/gateway-frontend/e2e/kakao-otp.spec.ts +++ b/app/gateway-frontend/e2e/kakao-otp.spec.ts @@ -133,7 +133,7 @@ const verifyDialog = async (page: Page, artifactName: string) => { 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$/ + /^rgba\(85, 115, 146, 0\.(?:49\d|5)\) 0px 0px 0px 4px$/ ); if (artifactRoot) { diff --git a/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts b/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts index 7efe4cf7..cf14b0bc 100644 --- a/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts +++ b/app/gateway-frontend/e2e/live-game-lifecycle.spec.ts @@ -60,8 +60,8 @@ 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.'); + if (login.status !== 'login') { + throw new Error(`Live lifecycle fixture requires login; received ${login.status}.`); } gatewaySession.token = login.sessionToken; const issued = await gateway.auth.issueGameSession.mutate({ diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 53957e96..304a5b83 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -21,6 +21,7 @@ export default defineConfig({ 'gateway-notice-html.spec.ts', 'kakao-otp.spec.ts', 'kakao-account-recovery.spec.ts', + 'account-deletion-recovery.spec.ts', 'public-map-tabs.spec.ts', 'runtime-navigation.spec.ts', 'open-suggestion.spec.ts', diff --git a/app/gateway-frontend/src/components/DeletionRecoveryPanel.vue b/app/gateway-frontend/src/components/DeletionRecoveryPanel.vue new file mode 100644 index 00000000..a06973da --- /dev/null +++ b/app/gateway-frontend/src/components/DeletionRecoveryPanel.vue @@ -0,0 +1,88 @@ + + + + + diff --git a/app/gateway-frontend/src/components/KakaoOtpDialog.vue b/app/gateway-frontend/src/components/KakaoOtpDialog.vue index 7a2c220a..d5851b69 100644 --- a/app/gateway-frontend/src/components/KakaoOtpDialog.vue +++ b/app/gateway-frontend/src/components/KakaoOtpDialog.vue @@ -1,6 +1,8 @@