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,
};
}),