feat: 탈퇴 취소와 이관 계정 복구 예외를 추가한다
This commit is contained in:
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>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
|
||||
Reference in new issue
Block a user