feat: 탈퇴 취소와 이관 계정 복구 예외를 추가한다

This commit is contained in:
Hide_D committed 2026-11-07 00:24:57 +09:00
1 parent f16d33e34c
commit 257a2bea10
21 files changed
+730 -109

No files matched your search

@@ -107,6 +107,7 @@ const EXPENSIVE_ACTIONS = new Set([
'auth.register',
'auth.registerLocal',
'auth.login',
'auth.cancelDeletion',
'auth.kakaoOtp',
'account.changePassword',
'account.scheduleDeletion',
@@ -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 <T extends 'login' | 'verified'>(
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 };
};
@@ -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<boolean> {
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<void> {
for (const [username, user] of usersByName.entries()) {
if (user.id === userId) {
@@ -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<DeletionRecovery, 'id'>): Promise<DeletionRecovery>;
consumeDeletionRecovery(id: string): Promise<DeletionRecovery | null>;
createPendingState(mode: OAuthMode, scopes: string[], userId?: string): Promise<OAuthPendingState>;
consumePendingState(state: string): Promise<OAuthPendingState | null>;
createSession(session: Omit<OAuthSession, 'id'>): Promise<OAuthSession>;
@@ -106,6 +116,20 @@ export class RedisOAuthSessionStore implements OAuthSessionStore {
private readonly prefix: string;
private readonly ttlSeconds: number;
async createDeletionRecovery(recovery: Omit<DeletionRecovery, 'id'>): Promise<DeletionRecovery> {
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<DeletionRecovery | null> {
const raw = await this.client.eval(consumeOnceScript, {
keys: [`${this.prefix}:deletion-recovery:${id}`],
arguments: [],
});
return typeof raw === 'string' ? parseJson<DeletionRecovery>(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<string, DeletionRecovery>();
async createDeletionRecovery(recovery: Omit<DeletionRecovery, 'id'>): Promise<DeletionRecovery> {
const stored = { ...recovery, id: randomUUID() };
this.deletionRecoveries.set(stored.id, stored);
return stored;
}
async consumeDeletionRecovery(id: string): Promise<DeletionRecovery | null> {
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<string, OAuthPendingState>();
private readonly sessions = new Map<string, OAuthSession>();
private readonly loginChallenges = new Map<string, KakaoLoginChallenge>();
@@ -789,6 +789,14 @@ export const createPostgresUserRepository = (
data: { deleteAfter },
});
},
async cancelDeletion(userId: string, deleteAfter: string, authRevision: number): Promise<boolean> {
// 인증한 탈퇴 예약만 취소하고, 이전 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<void> {
await prisma.appUser.delete({
where: { id: userId },
@@ -249,6 +249,7 @@ export interface UserRepository {
resetProfileIcon(userId: string, requestedAt: Date): Promise<string | null>;
setThirdPartyUse(userId: string, allowed: boolean): Promise<void>;
scheduleDeletion(userId: string, deleteAfter: Date): Promise<void>;
cancelDeletion(userId: string, deleteAfter: string, authRevision: number): Promise<boolean>;
deleteUser(userId: string): Promise<void>;
}
+37 -68
View File
@@ -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 <T extends 'login' | 'verified'>(
accessToken: string,
successStatus: T
) => {
assertLoginAllowed(user);
try {
const challenge = await requireKakaoTalkProof({
user,
@@ -87,13 +89,7 @@ const finishKakaoLogin = async <T extends 'login' | 'verified'>(
} 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 <T extends 'login' | 'verified'>(
@@ -102,6 +98,7 @@ const finishKakaoLoginOrRequestPasswordSetup = async <T extends 'login' | 'verif
accessToken: string,
successStatus: T
) => {
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,
};
}),
@@ -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);
});
});
+192 -29
View File
@@ -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);
});
});
@@ -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();
@@ -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<string, { recoveryId: string }>;
expect(Object.values(input)).toEqual([{ recoveryId: pending.recoveryId }]);
await new Promise<void>((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();
});
@@ -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);
+1 -1
View File
@@ -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) {
@@ -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({
@@ -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',
@@ -0,0 +1,88 @@
<script setup lang="ts">
import { ref } from 'vue';
import { formatServerDateTime } from '@sammo-ts/common/time/ServerDateTime';
import { trpc } from '../utils/trpc';
export interface DeletionPending {
recoveryId: string;
deleteAfter: string;
}
const props = defineProps<{ recovery: DeletionPending }>();
const emit = defineEmits<{ restored: [sessionToken: string]; cancel: [] }>();
const submitting = ref(false);
const errorMessage = ref('');
const restore = async (): Promise<void> => {
if (submitting.value) return;
submitting.value = true;
errorMessage.value = '';
try {
const result = await trpc.auth.cancelDeletion.mutate({ recoveryId: props.recovery.recoveryId });
if (result.status !== 'login') throw new Error('계정 상태가 변경되었습니다. 다시 로그인해 주세요.');
emit('restored', result.sessionToken);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '탈퇴를 취소하지 못했습니다.';
} finally {
submitting.value = false;
}
};
</script>
<template>
<section class="deletion-recovery" role="group" aria-label="탈퇴 취소">
<p>탈퇴 신청된 계정입니다.</p>
<p>삭제 예정일: {{ formatServerDateTime(recovery.deleteAfter) }}</p>
<p>계정을 계속 사용하려면 탈퇴를 취소해 주세요.</p>
<div class="actions">
<button type="button" :disabled="submitting" @click="restore">
{{ submitting ? '처리 중...' : '탈퇴 취소' }}
</button>
<button type="button" :disabled="submitting" @click="emit('cancel')">돌아가기</button>
</div>
<p v-if="errorMessage" class="recovery-error" role="alert">{{ errorMessage }}</p>
</section>
</template>
<style scoped>
.deletion-recovery {
padding: 18px;
text-align: center;
color: #fff;
}
.deletion-recovery p {
margin: 0 0 12px;
line-height: 1.6;
overflow-wrap: anywhere;
}
.actions {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.actions button {
min-height: 42px;
padding: 8px 16px;
border: 1px solid #2f4d6c;
border-radius: 4px;
background: #375a7f;
color: #fff;
cursor: pointer;
}
.actions button:hover,
.actions button:active {
background: #2f4d6c;
}
.actions button:focus-visible {
outline: 2px solid #f39c12;
outline-offset: 2px;
}
.actions button:disabled {
opacity: 0.6;
cursor: wait;
}
.recovery-error {
color: #ff8a80;
margin-top: 12px !important;
}
</style>
@@ -1,6 +1,8 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue';
import type { DeletionPending } from './DeletionRecoveryPanel.vue';
import { trpc } from '../utils/trpc';
const props = defineProps<{
@@ -10,6 +12,7 @@ const props = defineProps<{
const emit = defineEmits<{
verified: [sessionToken: string, validUntil: string];
cancel: [];
deletionPending: [recovery: DeletionPending];
}>();
const code = ref('');
@@ -36,6 +39,10 @@ const submit = async (): Promise<void> => {
challengeId: props.challengeId,
code: code.value,
});
if (result.status === 'deletion_pending') {
emit('deletionPending', result);
return;
}
emit('verified', result.sessionToken, result.validUntil);
} catch (error) {
errorMessage.value = error instanceof Error ? error.message : '인증 코드를 확인하지 못했습니다.';
@@ -831,7 +831,9 @@ onBeforeUnmount(() => {
현재 아이콘 서버 적용
</button>
</p>
<p class="warning">탈퇴시 1개월간 정보가 보존되며, 1개월간 재가입이 불가능합니다.</p>
<p class="warning">
탈퇴시 1개월간 정보가 보존되며, 보존 기간에는 로그인 후 탈퇴를 취소할 수 있습니다.
</p>
</td>
</tr>
</tfoot>
+19 -1
View File
@@ -7,6 +7,7 @@ import { gatewayProfileCapabilities } from '@sammo-ts/common/gateway/profileStat
import MapPreview from '../components/MapPreview.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import DeletionRecoveryPanel, { type DeletionPending } from '../components/DeletionRecoveryPanel.vue';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import { createGameTrpc, type GameRouter } from '../utils/gameTrpc';
import { trpc } from '../utils/trpc';
@@ -27,6 +28,12 @@ const username = ref('');
const password = ref('');
const loginError = ref('');
const loginLoading = ref(false);
const deletionPending = ref<DeletionPending | null>(null);
const handleDeletionPending = (recovery: DeletionPending): void => {
otpChallenge.value = null;
password.value = '';
deletionPending.value = recovery;
};
const otpChallenge = ref<{ challengeId: string; expiresAt: string; attemptsRemaining: number } | null>(null);
const statusLoading = ref(false);
const statusError = ref('');
@@ -101,6 +108,10 @@ const handleLogin = async (): Promise<void> => {
username: username.value,
credential,
});
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
if (result.status === 'otp') {
otpChallenge.value = result;
return;
@@ -148,7 +159,13 @@ const handlePasswordReset = async (): Promise<void> => {
<section id="login_card" class="login-card">
<h3>로그인</h3>
<form id="main_form" @submit.prevent="handleLogin">
<DeletionRecoveryPanel
v-if="deletionPending"
:recovery="deletionPending"
@restored="handleOtpVerified"
@cancel="deletionPending = null"
/>
<form v-else id="main_form" @submit.prevent="handleLogin">
<label for="username">계정명</label>
<input
id="username"
@@ -214,6 +231,7 @@ const handlePasswordReset = async (): Promise<void> => {
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@deletion-pending="handleDeletionPending"
@cancel="otpChallenge = null"
/>
</template>
@@ -4,12 +4,18 @@ import { useRoute, useRouter } from 'vue-router';
import DefaultLayout from '../layouts/DefaultLayout.vue';
import KakaoOtpDialog from '../components/KakaoOtpDialog.vue';
import DeletionRecoveryPanel, { type DeletionPending } from '../components/DeletionRecoveryPanel.vue';
import { trpc } from '../utils/trpc';
import { sealPassword } from '../utils/passwordEnvelope';
const route = useRoute();
const router = useRouter();
const loading = ref(true);
const deletionPending = ref<DeletionPending | null>(null);
const handleDeletionPending = (recovery: DeletionPending): void => {
otpChallenge.value = null;
deletionPending.value = recovery;
};
const submitting = ref(false);
const errorMessage = ref('');
const infoMessage = ref('');
@@ -43,6 +49,10 @@ const completeExchange = async (): Promise<void> => {
}
try {
const result = await trpc.auth.kakaoExchange.mutate({ code, state });
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
@@ -92,6 +102,10 @@ const resolveAccount = async (): Promise<void> => {
action: accountRecovery.value.action,
});
accountRecovery.value = null;
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
@@ -133,6 +147,10 @@ const setMigratedPassword = async (): Promise<void> => {
password.value = '';
confirmPassword.value = '';
passwordSetupSessionId.value = '';
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = passwordSetupSuccessStatus.value;
@@ -169,6 +187,10 @@ const register = async (): Promise<void> => {
privacyAgreed: true,
thirdPartyUse: thirdPartyUse.value,
});
if (result.status === 'deletion_pending') {
handleDeletionPending(result);
return;
}
if (result.status === 'otp') {
otpChallenge.value = result;
otpSuccessStatus.value = result.successStatus;
@@ -202,15 +224,23 @@ onMounted(() => {
<section class="oauth-card">
<h2>
{{
accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
deletionPending
? '탈퇴 취소'
: accountRecovery
? '카카오 계정 연결 확인'
: passwordSetupSessionId
? '새 비밀번호 설정'
: '회원가입'
}}
</h2>
<p v-if="loading" class="oauth-message">카카오 인증을 확인하는 중...</p>
<p v-else-if="infoMessage" class="oauth-message" role="status">{{ infoMessage }}</p>
<DeletionRecoveryPanel
v-else-if="deletionPending"
:recovery="deletionPending"
@restored="handleOtpVerified"
@cancel="router.replace('/')"
/>
<div v-else-if="accountRecovery" class="recovery-panel" role="group" aria-label="카카오 계정 연결 확인">
<p v-if="accountRecovery.action === 'link_existing'">
<strong>{{ accountRecovery.email }}</strong> 이메일로 보존된 기존 계정이 있습니다. 이 계정에
@@ -329,7 +359,9 @@ onMounted(() => {
</button>
</form>
<p v-if="errorMessage" class="oauth-error" role="alert">{{ errorMessage }}</p>
<RouterLink v-if="!loading && !oauthSessionId" class="back-link" to="/">돌아가기</RouterLink>
<RouterLink v-if="!loading && !oauthSessionId && !deletionPending" class="back-link" to="/"
>돌아가기</RouterLink
>
</section>
</main>
</DefaultLayout>
@@ -337,6 +369,7 @@ onMounted(() => {
v-if="otpChallenge"
:challenge-id="otpChallenge.challengeId"
@verified="handleOtpVerified"
@deletion-pending="handleDeletionPending"
@cancel="otpChallenge = null"
/>
</template>
@@ -114,7 +114,7 @@ const loginAdminWithToken = async () => {
username: requiredEnv('INITIAL_ADMIN_USERNAME'),
credential,
});
if (result.status === 'otp') throw new Error('Admin login unexpectedly requires OTP.');
if (result.status !== 'login') throw new Error(`Admin login requires ${result.status}.`);
session.token = result.sessionToken;
return { gateway, sessionToken: result.sessionToken };
};