diff --git a/app/game-api/src/auth/flushStore.ts b/app/game-api/src/auth/flushStore.ts index 594590ff..41ab28af 100644 --- a/app/game-api/src/auth/flushStore.ts +++ b/app/game-api/src/auth/flushStore.ts @@ -3,6 +3,8 @@ export interface GatewayUserFlushEvent { flushedAt: string; reason?: string; iconRevision?: string; + displayName?: string; + identityRevision?: string; } export interface FlushStore { diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index f2c0c3af..cc77befc 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -7,6 +7,7 @@ import { z } from 'zod'; import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken'; import { authedProcedure, engineProcedure, router } from '../../trpc.js'; import { enqueueProfileIconResetForUser } from '../../services/accountIconSync.js'; +import { enqueueAccountIdentityForUser } from '../../services/accountIdentitySync.js'; const parseDate = (value: string): Date | null => { const parsed = parseISO(value); @@ -86,6 +87,14 @@ export const authRouter = router({ // 관리자 reset만 다음 인증 경계에서 durable하게 복구한다. await enqueueProfileIconResetForUser(ctx, payload.user.id, payload.user.profileIconResetAt); } + if (payload.user.identityRevision) { + await enqueueAccountIdentityForUser( + ctx.turnDaemon, + payload.user.id, + payload.user.displayName, + payload.user.identityRevision + ); + } const created = await ctx.accessTokenStore.issueFromGateway(payload); if (created === 'ALREADY_USED') { diff --git a/app/game-api/src/server.ts b/app/game-api/src/server.ts index 5cb33387..da0517a0 100644 --- a/app/game-api/src/server.ts +++ b/app/game-api/src/server.ts @@ -42,6 +42,7 @@ import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js'; import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js'; import { CachedTurnEngineStatus } from './services/turnEngineStatus.js'; import { createAdminProfileIconResetFlushHandler } from './services/accountIconSync.js'; +import { createAccountIdentityFlushHandler } from './services/accountIdentitySync.js'; import { AccountIconResetReconciler } from './services/accountIconResetReconciler.js'; import { createBestEffortResourceCloser } from './services/bestEffortResourceCloser.js'; import { RemoteContentImageStore } from './services/remoteContentImageStore.js'; @@ -140,11 +141,15 @@ export const createGameApiServer = async () => { await postgres.disconnect(); throw error; } + const iconFlushHandler = createAdminProfileIconResetFlushHandler(accountIconSource, turnDaemon); + const identityFlushHandler = createAccountIdentityFlushHandler(turnDaemon); const flushSubscriber = new RedisGatewayFlushSubscriber( flushSubscriberClient, config.flushChannel, flushStore, - createAdminProfileIconResetFlushHandler(accountIconSource, turnDaemon), + async (event) => { + await Promise.all([iconFlushHandler(event), identityFlushHandler(event)]); + }, (error, event) => { app.log.error({ err: error, userId: event.userId, reason: event.reason }, 'gateway flush handler failed'); } diff --git a/app/game-api/src/services/accountIdentitySync.ts b/app/game-api/src/services/accountIdentitySync.ts new file mode 100644 index 00000000..6014a6ff --- /dev/null +++ b/app/game-api/src/services/accountIdentitySync.ts @@ -0,0 +1,27 @@ +import { isCanonicalIsoTimestamp } from '@sammo-ts/common'; + +import type { GatewayUserFlushEvent } from '../auth/flushStore.js'; +import type { TurnDaemonTransport } from '../daemon/transport.js'; + +export const enqueueAccountIdentityForUser = async ( + turnDaemon: TurnDaemonTransport, + userId: string, + displayName: string, + identityRevision: string +): Promise => { + await turnDaemon.sendCommand({ + type: 'adjustGeneralIdentity', + requestId: `general:adjustIdentity:${userId}:${identityRevision}`, + userId, + displayName, + identityRevision, + }); +}; + +export const createAccountIdentityFlushHandler = + (turnDaemon: TurnDaemonTransport) => + async (event: GatewayUserFlushEvent): Promise => { + if (event.reason !== 'admin-account-identity-updated') return; + if (!event.displayName || !event.identityRevision || !isCanonicalIsoTimestamp(event.identityRevision)) return; + await enqueueAccountIdentityForUser(turnDaemon, event.userId, event.displayName, event.identityRevision); + }; diff --git a/app/game-api/test/accountIdentityFlush.test.ts b/app/game-api/test/accountIdentityFlush.test.ts new file mode 100644 index 00000000..26d1b51f --- /dev/null +++ b/app/game-api/test/accountIdentityFlush.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest'; + +import { InMemoryTurnDaemonTransport } from '../src/daemon/inMemoryTransport.js'; +import { + createAccountIdentityFlushHandler, + enqueueAccountIdentityForUser, +} from '../src/services/accountIdentitySync.js'; + +describe('account identity projection', () => { + it('enqueues only a complete administrator identity update event', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const handler = createAccountIdentityFlushHandler(transport); + const revision = '2026-08-24T12:00:00.000Z'; + + await handler({ userId: 'user-1', flushedAt: revision, reason: 'admin-roles-updated' }); + await handler({ + userId: 'user-1', + flushedAt: revision, + reason: 'admin-account-identity-updated', + displayName: '새닉네임', + }); + expect(transport.commands).toHaveLength(0); + + await handler({ + userId: 'user-1', + flushedAt: revision, + reason: 'admin-account-identity-updated', + displayName: '새닉네임', + identityRevision: revision, + }); + expect(transport.commands.at(-1)?.command).toEqual({ + type: 'adjustGeneralIdentity', + requestId: `general:adjustIdentity:user-1:${revision}`, + userId: 'user-1', + displayName: '새닉네임', + identityRevision: revision, + }); + }); + + it('uses the same idempotency key at the next authentication boundary', async () => { + const transport = new InMemoryTurnDaemonTransport(); + const revision = '2026-08-24T12:00:00.000Z'; + + await enqueueAccountIdentityForUser(transport, 'user-1', '새닉네임', revision); + + expect(transport.commands.at(-1)?.command.requestId).toBe(`general:adjustIdentity:user-1:${revision}`); + }); +}); diff --git a/app/game-engine/src/turn/commandRegistry.ts b/app/game-engine/src/turn/commandRegistry.ts index 7c3110a8..d9b00d3e 100644 --- a/app/game-engine/src/turn/commandRegistry.ts +++ b/app/game-engine/src/turn/commandRegistry.ts @@ -355,6 +355,16 @@ const zAdjustGeneralIcon = z }) .strict(); +const zAdjustGeneralIdentity = z + .object({ + type: z.literal('adjustGeneralIdentity'), + requestId: z.string().min(1).optional(), + userId: z.string().min(1), + displayName: z.string().min(1), + identityRevision: z.string().refine(isCanonicalIsoTimestamp), + }) + .strict(); + const zJoinCreateGeneral = z .object({ type: z.literal('joinCreateGeneral'), @@ -745,6 +755,14 @@ const normalizeAdjustGeneralIcon: CommandNormalizer<'adjustGeneralIcon'> = (enve return { ...command, requestId: envelope.requestId }; }; +const normalizeAdjustGeneralIdentity: CommandNormalizer<'adjustGeneralIdentity'> = (envelope) => { + const command = parseWith(zAdjustGeneralIdentity, envelope.command); + if (!command || (command.requestId !== undefined && command.requestId !== envelope.requestId)) { + return null; + } + return { ...command, requestId: envelope.requestId }; +}; + const normalizeJoinCreateGeneral: CommandNormalizer<'joinCreateGeneral'> = (envelope) => { const command = parseWith(zJoinCreateGeneral, envelope.command); if (!command) { @@ -861,6 +879,7 @@ const normalizers: CommandNormalizerMap = { patchGeneral: normalizePatchGeneral, inheritanceAction: normalizeInheritanceAction, adjustGeneralIcon: normalizeAdjustGeneralIcon, + adjustGeneralIdentity: normalizeAdjustGeneralIdentity, joinCreateGeneral: normalizeJoinCreateGeneral, npcPossessGeneral: normalizeNpcPossessGeneral, selectPoolReserve: normalizeSelectPoolReserve, diff --git a/app/game-engine/src/turn/worldCommandHandler.ts b/app/game-engine/src/turn/worldCommandHandler.ts index dcfb036a..5daa61eb 100644 --- a/app/game-engine/src/turn/worldCommandHandler.ts +++ b/app/game-engine/src/turn/worldCommandHandler.ts @@ -242,6 +242,7 @@ const resolveCommandAcceptedAt = async ( | 'selectPoolCreate' | 'selectPoolReselect' | 'adjustGeneralIcon' + | 'adjustGeneralIdentity' | 'inheritanceAction' | 'setNationSetting' | 'setNpcPolicy'; @@ -989,6 +990,62 @@ async function handleAdjustGeneralIcon( }; } +async function handleAdjustGeneralIdentity( + ctx: CommandHandlerContext, + command: Extract +): Promise { + await resolveCommandAcceptedAt(requireCommandDatabase(ctx), command); + const general = ctx.world + .listGenerals() + .find((candidate) => candidate.userId === command.userId && candidate.npcState === 0); + if (!general) { + return { type: 'adjustGeneralIdentity', ok: true, generalId: null, updated: false }; + } + const currentRevision = general.meta.ownerIdentityRevision; + if (currentRevision !== undefined) { + if (typeof currentRevision !== 'string' || !isCanonicalIsoTimestamp(currentRevision)) { + return { + type: 'adjustGeneralIdentity', + ok: false, + code: 'PRECONDITION_FAILED', + reason: '장수의 계정 이름 revision이 올바르지 않습니다.', + }; + } + const currentTime = new Date(currentRevision).getTime(); + const nextTime = new Date(command.identityRevision).getTime(); + if (nextTime < currentTime) { + return { + type: 'adjustGeneralIdentity', + ok: false, + code: 'CONFLICT', + reason: '더 최신 계정 이름이 이미 적용되었습니다.', + }; + } + if (nextTime === currentTime) { + const currentName = general.meta.ownerDisplayName ?? general.meta.ownerName ?? general.meta.owner_name; + if (currentName === command.displayName) { + return { type: 'adjustGeneralIdentity', ok: true, generalId: general.id, updated: false }; + } + return { + type: 'adjustGeneralIdentity', + ok: false, + code: 'CONFLICT', + reason: '같은 revision에 다른 계정 이름이 요청되었습니다.', + }; + } + } + ctx.world.updateGeneral(general.id, { + meta: { + ...general.meta, + ownerDisplayName: command.displayName, + ownerName: command.displayName, + owner_name: command.displayName, + ownerIdentityRevision: command.identityRevision, + }, + }); + return { type: 'adjustGeneralIdentity', ok: true, generalId: general.id, updated: true }; +} + async function handleShiftSchedule( ctx: CommandHandlerContext, command: Extract @@ -3120,6 +3177,8 @@ export const createTurnDaemonCommandHandler = (options: { handleInheritanceAction(ctx, command as Extract), adjustGeneralIcon: (command) => handleAdjustGeneralIcon(ctx, command as Extract), + adjustGeneralIdentity: (command) => + handleAdjustGeneralIdentity(ctx, command as Extract), joinCreateGeneral: (command) => handleJoinCreateGeneral(ctx, command as Extract), npcPossessGeneral: (command) => diff --git a/app/game-engine/test/accountIconCommand.test.ts b/app/game-engine/test/accountIconCommand.test.ts index 5afc50d9..519f8807 100644 --- a/app/game-engine/test/accountIconCommand.test.ts +++ b/app/game-engine/test/accountIconCommand.test.ts @@ -100,6 +100,18 @@ const buildCommandDb = (actorUserId = 'user-1', acceptedAt = new Date('2026-07-3 }, }) as unknown as GamePrisma.TransactionClient; +const buildIdentityCommandDb = (actorUserId = 'user-1') => + ({ + inputEvent: { + findUnique: vi.fn(async () => ({ + createdAt: new Date(revision), + actorUserId, + target: 'ENGINE', + eventType: 'adjustGeneralIdentity', + })), + }, + }) as unknown as GamePrisma.TransactionClient; + const command = ( overrides: Partial<{ requestId: string; @@ -220,3 +232,43 @@ describe('adjustGeneralIcon ENGINE command', () => { }); }); }); + +describe('adjustGeneralIdentity ENGINE command', () => { + it('updates only the current human general owner-name projection', async () => { + const world = buildWorld([ + buildGeneral({ meta: { killturn: 24, ownerName: '이전닉네임' } }), + buildGeneral({ id: 2, npcState: 1, meta: { killturn: 24, ownerName: '이전닉네임' } }), + ]); + const handler = createTurnDaemonCommandHandler({ world }); + const identityCommand = { + type: 'adjustGeneralIdentity' as const, + requestId: `general:adjustIdentity:user-1:${revision}`, + userId: 'user-1', + displayName: '새닉네임', + identityRevision: revision, + }; + + await expect(handler.handle(identityCommand, { db: buildIdentityCommandDb() })).resolves.toEqual({ + type: 'adjustGeneralIdentity', + ok: true, + generalId: 1, + updated: true, + }); + expect(world.getGeneralById(1)?.meta).toMatchObject({ + killturn: 24, + ownerDisplayName: '새닉네임', + ownerName: '새닉네임', + owner_name: '새닉네임', + ownerIdentityRevision: revision, + }); + expect(world.getGeneralById(2)?.meta).toMatchObject({ ownerName: '이전닉네임' }); + + await expect(handler.handle(identityCommand, { db: buildIdentityCommandDb() })).resolves.toMatchObject({ + ok: true, + updated: false, + }); + await expect( + handler.handle({ ...identityCommand, displayName: '충돌닉네임' }, { db: buildIdentityCommandDb() }) + ).resolves.toMatchObject({ ok: false, code: 'CONFLICT' }); + }); +}); diff --git a/app/gateway-api/src/account/router.ts b/app/gateway-api/src/account/router.ts index 3ebcec6b..029ab3cf 100644 --- a/app/gateway-api/src/account/router.ts +++ b/app/gateway-api/src/account/router.ts @@ -9,6 +9,7 @@ import { procedure, router } from '../trpc.js'; import type { UserRecord, UserSanctions } from '../auth/userRepository.js'; import { openPassword, zPasswordEnvelope } from '../auth/registrationInput.js'; import { resolveEffectiveAccountIcon } from '../auth/accountIconProjection.js'; +import { isGatewaySessionCurrent } from '../auth/sessionValidity.js'; import { WEB_PUSH_EVENT_TYPES } from '@sammo-ts/common'; const zSessionToken = z.string().min(1); @@ -31,7 +32,7 @@ const requireSessionUser = async (ctx: GatewayApiContext, sessionToken: string): throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' }); } const user = await ctx.users.findById(session.userId); - if (!user) { + if (!user || !isGatewaySessionCurrent(session, user)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'User no longer exists.' }); } return user; @@ -205,6 +206,7 @@ export const accountRouter = router({ displayName: user.displayName, roles: user.roles, oauthType: user.oauthType, + kakaoReplacementApprovedUntil: user.kakaoReplacementApprovedUntil ?? null, createdAt: user.createdAt, iconUrl: buildIconUrl(ctx, user), icons: icons.map((icon) => buildLibraryIcon(ctx, icon)), diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 98138d1d..132f9ad1 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -25,6 +25,7 @@ import { } from './adminCapabilities.js'; import type { GatewayApiContext } from './context.js'; import { resolveLocalAccountProfilePolicy } from './auth/localAccountPolicy.js'; +import { isGatewaySessionCurrent } from './auth/sessionValidity.js'; import { GATEWAY_BUILD_STATUSES, GATEWAY_PROFILE_STATUSES, @@ -37,6 +38,7 @@ import { resolveGatewayProfileKoreanName, } from './profileOrder.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; +import { zDisplayName, zRegistrationUsername } from './auth/registrationInput.js'; const zProfileStatus = z.enum(GATEWAY_PROFILE_STATUSES); const zBuildStatus = z.enum(GATEWAY_BUILD_STATUSES); @@ -114,7 +116,7 @@ const resolveAdminAuth = async (ctx: GatewayApiContext): Promise { + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + const adminAuth = requireAdminAuth(ctx); + assertTargetUserManageable(adminAuth, user); + const until = input.until ? new Date(input.until) : null; + const now = new Date(); + if (until) { + if (user.oauthType !== 'KAKAO' || !user.oauthId) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '현재 카카오 계정이 연결된 사용자만 교체를 승인할 수 있습니다.', + }); + } + if (until <= now || until.getTime() > now.getTime() + 7 * 24 * 60 * 60 * 1000) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '교체 승인 만료는 현재부터 7일 이내여야 합니다.', + }); + } + } + const updated = await ctx.users.setKakaoReplacementApproval(input.userId, { + until, + approvedByUserId: adminAuth.user.id, + reason: input.reason, + }); + return { kakaoReplacementApprovedUntil: updated.kakaoReplacementApprovedUntil ?? null }; + }), + updateIdentity: userAdminProcedure + .input( + z.object({ + userId: z.string().min(1), + username: zRegistrationUsername, + displayName: zDisplayName, + reason: z.string().trim().min(3).max(200), + }) + ) + .mutation(async ({ ctx, input }) => { + const user = await ctx.users.findById(input.userId); + if (!user) { + throw new TRPCError({ code: 'NOT_FOUND', message: 'User not found.' }); + } + assertTargetUserManageable(requireAdminAuth(ctx), user); + let updated; + try { + updated = await ctx.users.updateIdentity(input.userId, { + username: input.username, + displayName: input.displayName, + changedAt: new Date(), + }); + } catch (error) { + throw new TRPCError({ + code: 'CONFLICT', + message: '이미 사용 중인 ID 또는 닉네임입니다.', + cause: error, + }); + } + await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-account-identity-updated', { + displayName: updated.displayName, + identityRevision: updated.identityRevision, + }); + return { + username: updated.username, + displayName: updated.displayName, + identityRevision: updated.identityRevision, + }; + }), listHistory: userAdminProcedure .input(z.object({ userId: z.string().min(1), limit: z.number().int().min(1).max(200).optional() })) .query(({ ctx, input }) => diff --git a/app/gateway-api/src/auth/flushPublisher.ts b/app/gateway-api/src/auth/flushPublisher.ts index 3b5b156c..86cce640 100644 --- a/app/gateway-api/src/auth/flushPublisher.ts +++ b/app/gateway-api/src/auth/flushPublisher.ts @@ -3,10 +3,16 @@ export interface GatewayUserFlushEvent { flushedAt: string; reason?: string; iconRevision?: string; + displayName?: string; + identityRevision?: string; } export interface GatewayFlushPublisher { - publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise; + publishUserFlush( + userId: string, + reason?: string, + metadata?: { iconRevision?: string; displayName?: string; identityRevision?: string } + ): Promise; } export class RedisGatewayFlushPublisher implements GatewayFlushPublisher { @@ -18,12 +24,18 @@ export class RedisGatewayFlushPublisher implements GatewayFlushPublisher { this.channel = channel; } - async publishUserFlush(userId: string, reason?: string, metadata?: { iconRevision?: string }): Promise { + async publishUserFlush( + userId: string, + reason?: string, + metadata?: { iconRevision?: string; displayName?: string; identityRevision?: string } + ): Promise { const payload: GatewayUserFlushEvent = { userId, flushedAt: new Date().toISOString(), reason, ...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}), + ...(metadata?.displayName ? { displayName: metadata.displayName } : {}), + ...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}), }; await this.client.publish(this.channel, JSON.stringify(payload)); } diff --git a/app/gateway-api/src/auth/inMemorySessionService.ts b/app/gateway-api/src/auth/inMemorySessionService.ts index 2b453c1b..62669df6 100644 --- a/app/gateway-api/src/auth/inMemorySessionService.ts +++ b/app/gateway-api/src/auth/inMemorySessionService.ts @@ -45,6 +45,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { sanctions: user.sanctions, createdAt: user.createdAt, issuedAt: new Date().toISOString(), + authRevision: user.authRevision ?? 0, legacyMemberNo: user.legacyMemberNo, }; this.sessions.set(sessionToken, { @@ -100,6 +101,7 @@ export class InMemoryGatewaySessionService implements GatewaySessionService { sanctions: session.sanctions, createdAt: session.createdAt, issuedAt: new Date().toISOString(), + authRevision: session.authRevision ?? 0, legacyMemberNo: session.legacyMemberNo, }; const key = buildGameKey(profile, gameToken); diff --git a/app/gateway-api/src/auth/inMemoryUserRepository.ts b/app/gateway-api/src/auth/inMemoryUserRepository.ts index ab4779fd..a0bbad68 100644 --- a/app/gateway-api/src/auth/inMemoryUserRepository.ts +++ b/app/gateway-api/src/auth/inMemoryUserRepository.ts @@ -28,6 +28,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const usersByName = new Map(); const usersByOauthId = new Map(); const usersByEmail = new Map(); + const retiredKakaoIds = new Set(); const iconsById = new Map(); const specialAccessGrantsById = new Map(); @@ -101,7 +102,8 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass } if ( input.oauth && - (usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) || + (retiredKakaoIds.has(input.oauth.id) || + usersByOauthId.has(`${input.oauth.type}:${input.oauth.id}`) || usersByEmail.has(input.oauth.email.toLowerCase())) ) { throw new Error('Kakao account already linked.'); @@ -124,6 +126,8 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass oauthId: input.oauth?.id, email: input.oauth?.email, oauthInfo: input.oauth?.info, + identityRevision: now.toISOString(), + authRevision: 0, picture: 'default.jpg', imageServer: 0, thirdPartyUse: input.thirdPartyUse ?? false, @@ -213,7 +217,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const normalizedEmail = input.email.toLowerCase(); const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`); const emailOwner = usersByEmail.get(normalizedEmail); - if ((oauthOwner && oauthOwner.id !== userId) || (emailOwner && emailOwner.id !== userId)) { + if ( + retiredKakaoIds.has(input.oauthId) || + (oauthOwner && oauthOwner.id !== userId) || + (emailOwner && emailOwner.id !== userId) + ) { throw new Error('Kakao account already linked.'); } for (const user of usersByName.values()) { @@ -242,7 +250,11 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass const normalizedEmail = input.email.toLowerCase(); const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`); const emailOwner = usersByEmail.get(normalizedEmail); - if ((oauthOwner && oauthOwner.id !== userId) || emailOwner?.id !== userId) { + if ( + retiredKakaoIds.has(input.oauthId) || + (oauthOwner && oauthOwner.id !== userId) || + emailOwner?.id !== userId + ) { throw new Error('Kakao account recovery ownership changed.'); } for (const user of usersByName.values()) { @@ -262,6 +274,86 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass } throw new Error('Kakao account recovery ownership changed.'); }, + async isKakaoIdentityRetired(oauthId: string): Promise { + return retiredKakaoIds.has(oauthId); + }, + async setKakaoReplacementApproval(userId, input): Promise { + for (const user of usersByName.values()) { + if (user.id !== userId) continue; + user.kakaoReplacementApprovedUntil = input.until?.toISOString(); + user.kakaoReplacementApprovedByUserId = input.until ? input.approvedByUserId : undefined; + user.kakaoReplacementReason = input.until ? input.reason : undefined; + return user; + } + throw new Error('User not found.'); + }, + async replaceKakaoWithApprovedIdentity(userId, input): Promise { + const normalizedEmail = input.email.toLowerCase(); + const oauthOwner = usersByOauthId.get(`KAKAO:${input.oauthId}`); + const emailOwner = usersByEmail.get(normalizedEmail); + for (const user of usersByName.values()) { + if (user.id !== userId) continue; + const approvedUntil = user.kakaoReplacementApprovedUntil + ? new Date(user.kakaoReplacementApprovedUntil) + : null; + if ( + user.oauthType !== 'KAKAO' || + !user.oauthId || + user.oauthId === input.oauthId || + !approvedUntil || + approvedUntil < input.verifiedAt || + !user.kakaoReplacementApprovedByUserId || + !user.kakaoReplacementReason || + retiredKakaoIds.has(input.oauthId) || + (oauthOwner && oauthOwner.id !== userId) || + (emailOwner && emailOwner.id !== userId) + ) { + throw new Error('Kakao account replacement is not approved.'); + } + retiredKakaoIds.add(user.oauthId); + usersByOauthId.delete(`KAKAO:${user.oauthId}`); + if (user.email) usersByEmail.delete(user.email.toLowerCase()); + user.oauthId = input.oauthId; + user.email = normalizedEmail; + user.oauthInfo = input.oauthInfo; + user.kakaoVerifiedAt = input.verifiedAt.toISOString(); + user.kakaoTalkVerifiedUntil = undefined; + user.kakaoReplacementApprovedUntil = undefined; + user.kakaoReplacementApprovedByUserId = undefined; + user.kakaoReplacementReason = undefined; + user.sessionRevokedBefore = input.verifiedAt.toISOString(); + user.authRevision = (user.authRevision ?? 0) + 1; + usersByOauthId.set(`KAKAO:${input.oauthId}`, user); + usersByEmail.set(normalizedEmail, user); + return user; + } + throw new Error('User not found.'); + }, + async updateIdentity(userId, input): Promise { + const usernameOwner = usersByName.get(input.username); + const displayNameOwner = [...usersByName.values()].find( + (candidate) => candidate.displayName === input.displayName + ); + if ( + (usernameOwner && usernameOwner.id !== userId) || + (displayNameOwner && displayNameOwner.id !== userId) + ) { + throw new Error('Account identity already exists.'); + } + for (const [username, user] of usersByName.entries()) { + if (user.id !== userId) continue; + const nextRevision = new Date( + Math.max(input.changedAt.getTime(), new Date(user.identityRevision ?? user.createdAt).getTime() + 1) + ).toISOString(); + usersByName.delete(username); + user.username = input.username; + user.displayName = input.displayName; + user.identityRevision = nextRevision; + usersByName.set(input.username, user); + return user; + } + throw new Error('User not found.'); + }, async updateRoles(userId: string, roles: string[]): Promise { for (const user of usersByName.values()) { if (user.id === userId) { diff --git a/app/gateway-api/src/auth/postgresUserRepository.ts b/app/gateway-api/src/auth/postgresUserRepository.ts index d9c6a123..75aceea3 100644 --- a/app/gateway-api/src/auth/postgresUserRepository.ts +++ b/app/gateway-api/src/auth/postgresUserRepository.ts @@ -66,6 +66,12 @@ const mapUser = (row: { oauthId: string | null; email: string | null; oauthInfo: GatewayPrisma.JsonValue; + identityRevision: Date; + authRevision: number; + sessionRevokedBefore: Date | null; + kakaoReplacementApprovedUntil: Date | null; + kakaoReplacementApprovedByUserId: string | null; + kakaoReplacementReason: string | null; picture: string; imageServer: number; iconUpdatedAt: Date | null; @@ -92,6 +98,12 @@ const mapUser = (row: { oauthId: row.oauthId ?? undefined, email: row.email ?? undefined, oauthInfo: readObject(row.oauthInfo, {}), + identityRevision: row.identityRevision.toISOString(), + authRevision: row.authRevision, + sessionRevokedBefore: row.sessionRevokedBefore?.toISOString(), + kakaoReplacementApprovedUntil: row.kakaoReplacementApprovedUntil?.toISOString(), + kakaoReplacementApprovedByUserId: row.kakaoReplacementApprovedByUserId ?? undefined, + kakaoReplacementReason: row.kakaoReplacementReason ?? undefined, picture: row.picture, imageServer: row.imageServer, iconUpdatedAt: row.iconUpdatedAt?.toISOString(), @@ -246,26 +258,39 @@ export const createPostgresUserRepository = ( const password = await hasher.hash(input.password); const oauthType = input.oauth?.type ?? 'NONE'; const now = new Date(); - const row = await prisma.appUser.create({ - data: { - loginId: input.username, - displayName: input.displayName ?? input.username, - passwordHash: password.hash, - passwordSalt: password.salt, - passwordResetRequired: false, - roles: ['user'] satisfies GatewayPrisma.JsonArray, - sanctions: {} satisfies GatewayPrisma.JsonObject, - oauthType, - oauthId: input.oauth?.id, - email: input.oauth?.email?.toLowerCase(), - oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject, - termsAcceptedAt: input.termsAcceptedAt, - privacyAcceptedAt: input.privacyAcceptedAt, - thirdPartyUse: input.thirdPartyUse ?? false, - kakaoVerifiedAt: input.oauth ? now : undefined, - kakaoGraceStartedAt: now, - }, - }); + const data: GatewayPrisma.AppUserCreateInput = { + loginId: input.username, + displayName: input.displayName ?? input.username, + passwordHash: password.hash, + passwordSalt: password.salt, + passwordResetRequired: false, + roles: ['user'] satisfies GatewayPrisma.JsonArray, + sanctions: {} satisfies GatewayPrisma.JsonObject, + oauthType, + oauthId: input.oauth?.id, + email: input.oauth?.email?.toLowerCase(), + oauthInfo: (input.oauth?.info ?? {}) as GatewayPrisma.JsonObject, + termsAcceptedAt: input.termsAcceptedAt, + privacyAcceptedAt: input.privacyAcceptedAt, + thirdPartyUse: input.thirdPartyUse ?? false, + kakaoVerifiedAt: input.oauth ? now : undefined, + kakaoGraceStartedAt: now, + }; + const row = input.oauth + ? await prisma.$transaction( + async (tx) => { + const retired = await tx.retiredKakaoIdentity.findUnique({ + where: { oauthId: input.oauth!.id }, + select: { id: true }, + }); + if (retired) { + throw new Error('Kakao identity is permanently retired.'); + } + return tx.appUser.create({ data }); + }, + { isolationLevel: 'Serializable' } + ) + : await prisma.appUser.create({ data }); return mapUser(row); }, async verifyPassword(user: UserRecord, password: string): Promise { @@ -323,40 +348,188 @@ export const createPostgresUserRepository = ( return mapUser(row); }, async linkKakao(userId, input): Promise { - const row = await prisma.appUser.update({ - where: { id: userId }, - data: { - oauthType: 'KAKAO', - oauthId: input.oauthId, - email: input.email.toLowerCase(), - oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, - kakaoVerifiedAt: input.verifiedAt, - kakaoTalkVerifiedUntil: null, + return prisma.$transaction( + async (tx) => { + const retired = await tx.retiredKakaoIdentity.findUnique({ + where: { oauthId: input.oauthId }, + select: { id: true }, + }); + if (retired) { + throw new Error('Kakao identity is permanently retired.'); + } + const row = await tx.appUser.update({ + where: { id: userId }, + data: { + oauthType: 'KAKAO', + oauthId: input.oauthId, + email: input.email.toLowerCase(), + oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, + kakaoVerifiedAt: input.verifiedAt, + kakaoTalkVerifiedUntil: null, + }, + }); + return mapUser(row); }, - }); - return mapUser(row); + { isolationLevel: 'Serializable' } + ); }, async relinkKakaoByEmail(userId, input): Promise { const normalizedEmail = input.email.toLowerCase(); - const updated = await prisma.appUser.updateMany({ - where: { - id: userId, - email: normalizedEmail, - }, - data: { - oauthType: 'KAKAO', - oauthId: input.oauthId, - oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, - kakaoVerifiedAt: input.verifiedAt, - kakaoTalkVerifiedUntil: null, + return prisma.$transaction( + async (tx) => { + const retired = await tx.retiredKakaoIdentity.findUnique({ + where: { oauthId: input.oauthId }, + select: { id: true }, + }); + if (retired) { + throw new Error('Kakao identity is permanently retired.'); + } + const updated = await tx.appUser.updateMany({ + where: { + id: userId, + email: normalizedEmail, + }, + data: { + oauthType: 'KAKAO', + oauthId: input.oauthId, + oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, + kakaoVerifiedAt: input.verifiedAt, + kakaoTalkVerifiedUntil: null, + }, + }); + if (updated.count !== 1) { + throw new Error('Kakao account recovery ownership changed.'); + } + const row = await tx.appUser.findUniqueOrThrow({ where: { id: userId } }); + return mapUser(row); }, + { isolationLevel: 'Serializable' } + ); + }, + async isKakaoIdentityRetired(oauthId: string): Promise { + return (await prisma.retiredKakaoIdentity.count({ where: { oauthId } })) > 0; + }, + async setKakaoReplacementApproval(userId, input): Promise { + const row = await prisma.appUser.update({ + where: { id: userId }, + data: input.until + ? { + kakaoReplacementApprovedUntil: input.until, + kakaoReplacementApprovedByUserId: input.approvedByUserId, + kakaoReplacementReason: input.reason, + } + : { + kakaoReplacementApprovedUntil: null, + kakaoReplacementApprovedByUserId: null, + kakaoReplacementReason: null, + }, }); - if (updated.count !== 1) { - throw new Error('Kakao account recovery ownership changed.'); - } - const row = await prisma.appUser.findUniqueOrThrow({ where: { id: userId } }); return mapUser(row); }, + async replaceKakaoWithApprovedIdentity(userId, input): Promise { + return prisma.$transaction( + async (tx) => { + const user = await tx.appUser.findUnique({ where: { id: userId } }); + const now = input.verifiedAt; + if ( + !user || + user.oauthType !== 'KAKAO' || + !user.oauthId || + user.oauthId === input.oauthId || + !user.kakaoReplacementApprovedUntil || + user.kakaoReplacementApprovedUntil < now || + !user.kakaoReplacementApprovedByUserId || + !user.kakaoReplacementReason + ) { + throw new Error('Kakao account replacement is not approved.'); + } + const normalizedEmail = input.email.toLowerCase(); + const [oauthOwner, emailOwner, retired] = await Promise.all([ + tx.appUser.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), + tx.appUser.findUnique({ where: { email: normalizedEmail }, select: { id: true } }), + tx.retiredKakaoIdentity.findUnique({ where: { oauthId: input.oauthId }, select: { id: true } }), + ]); + if ( + retired || + (oauthOwner && oauthOwner.id !== userId) || + (emailOwner && emailOwner.id !== userId) + ) { + throw new Error('Kakao account replacement ownership changed.'); + } + await tx.retiredKakaoIdentity.create({ + data: { + oauthId: user.oauthId, + formerUserId: user.id, + approvedByUserId: user.kakaoReplacementApprovedByUserId, + reason: user.kakaoReplacementReason, + retiredAt: now, + }, + }); + const updated = await tx.appUser.updateMany({ + where: { + id: userId, + oauthType: 'KAKAO', + oauthId: user.oauthId, + kakaoReplacementApprovedUntil: { gte: now }, + kakaoReplacementApprovedByUserId: user.kakaoReplacementApprovedByUserId, + kakaoReplacementReason: user.kakaoReplacementReason, + }, + data: { + oauthType: 'KAKAO', + oauthId: input.oauthId, + email: normalizedEmail, + oauthInfo: input.oauthInfo as GatewayPrisma.JsonObject, + kakaoVerifiedAt: now, + kakaoTalkVerifiedUntil: null, + kakaoReplacementApprovedUntil: null, + kakaoReplacementApprovedByUserId: null, + kakaoReplacementReason: null, + sessionRevokedBefore: now, + authRevision: { increment: 1 }, + }, + }); + if (updated.count !== 1) { + throw new Error('Kakao account replacement approval changed.'); + } + const row = await tx.appUser.findUniqueOrThrow({ where: { id: userId } }); + return mapUser(row); + }, + { isolationLevel: 'Serializable' } + ); + }, + async updateIdentity(userId, input): Promise { + return prisma.$transaction( + async (tx) => { + const current = await tx.appUser.findUnique({ where: { id: userId } }); + if (!current) { + throw new Error('User not found.'); + } + const [usernameOwner, displayNameOwner] = await Promise.all([ + tx.appUser.findUnique({ where: { loginId: input.username }, select: { id: true } }), + tx.appUser.findUnique({ where: { displayName: input.displayName }, select: { id: true } }), + ]); + if ( + (usernameOwner && usernameOwner.id !== userId) || + (displayNameOwner && displayNameOwner.id !== userId) + ) { + throw new Error('Account identity already exists.'); + } + const nextRevision = new Date( + Math.max(input.changedAt.getTime(), current.identityRevision.getTime() + 1) + ); + const row = await tx.appUser.update({ + where: { id: userId }, + data: { + loginId: input.username, + displayName: input.displayName, + identityRevision: nextRevision, + }, + }); + return mapUser(row); + }, + { isolationLevel: 'Serializable' } + ); + }, async updateRoles(userId: string, roles: string[]): Promise { await prisma.appUser.update({ where: { id: userId }, diff --git a/app/gateway-api/src/auth/redisSessionService.ts b/app/gateway-api/src/auth/redisSessionService.ts index ffe336c9..e2fe9ecb 100644 --- a/app/gateway-api/src/auth/redisSessionService.ts +++ b/app/gateway-api/src/auth/redisSessionService.ts @@ -56,6 +56,7 @@ export class RedisGatewaySessionService implements GatewaySessionService { sanctions: user.sanctions, createdAt: user.createdAt, issuedAt: new Date().toISOString(), + authRevision: user.authRevision ?? 0, legacyMemberNo: user.legacyMemberNo, }; await this.client.set(this.keys.sessionKey(sessionToken), JSON.stringify(info), { @@ -109,6 +110,7 @@ export class RedisGatewaySessionService implements GatewaySessionService { sanctions: session.sanctions, createdAt: session.createdAt, issuedAt: new Date().toISOString(), + authRevision: session.authRevision ?? 0, legacyMemberNo: session.legacyMemberNo, }; const gameKey = this.keys.gameSessionKey(profile, gameToken); diff --git a/app/gateway-api/src/auth/sessionService.ts b/app/gateway-api/src/auth/sessionService.ts index cc601d69..387514fa 100644 --- a/app/gateway-api/src/auth/sessionService.ts +++ b/app/gateway-api/src/auth/sessionService.ts @@ -9,6 +9,7 @@ export interface GatewaySessionInfo { sanctions: UserSanctions; createdAt: string; issuedAt: string; + authRevision?: number; legacyMemberNo?: number; } @@ -23,6 +24,7 @@ export interface GameSessionInfo { sanctions: UserSanctions; createdAt: string; issuedAt: string; + authRevision?: number; legacyMemberNo?: number; } diff --git a/app/gateway-api/src/auth/sessionValidity.ts b/app/gateway-api/src/auth/sessionValidity.ts new file mode 100644 index 00000000..eed47a76 --- /dev/null +++ b/app/gateway-api/src/auth/sessionValidity.ts @@ -0,0 +1,10 @@ +import type { GatewaySessionInfo } from './sessionService.js'; +import type { UserRecord } from './userRepository.js'; + +export const isGatewaySessionCurrent = (session: GatewaySessionInfo, user: UserRecord): boolean => { + if ((session.authRevision ?? 0) !== (user.authRevision ?? 0)) return false; + if (!user.sessionRevokedBefore) return true; + const issuedAt = new Date(session.issuedAt).getTime(); + const revokedBefore = new Date(user.sessionRevokedBefore).getTime(); + return Number.isFinite(issuedAt) && Number.isFinite(revokedBefore) && issuedAt >= revokedBefore; +}; diff --git a/app/gateway-api/src/auth/userRepository.ts b/app/gateway-api/src/auth/userRepository.ts index 796b81c9..d53db27d 100644 --- a/app/gateway-api/src/auth/userRepository.ts +++ b/app/gateway-api/src/auth/userRepository.ts @@ -8,6 +8,12 @@ export interface UserRecord { oauthId?: string; email?: string; oauthInfo?: UserOAuthInfo; + identityRevision?: string; + authRevision?: number; + sessionRevokedBefore?: string; + kakaoReplacementApprovedUntil?: string; + kakaoReplacementApprovedByUserId?: string; + kakaoReplacementReason?: string; picture: string; imageServer: number; iconUpdatedAt?: string; @@ -181,6 +187,24 @@ export interface UserRepository { verifiedAt: Date; } ): Promise; + isKakaoIdentityRetired(oauthId: string): Promise; + setKakaoReplacementApproval( + userId: string, + input: { until: Date | null; approvedByUserId: string; reason: string } + ): Promise; + replaceKakaoWithApprovedIdentity( + userId: string, + input: { + oauthId: string; + email: string; + oauthInfo: UserOAuthInfo; + verifiedAt: Date; + } + ): Promise; + updateIdentity( + userId: string, + input: { username: string; displayName: string; changedAt: Date } + ): Promise; updateRoles(userId: string, roles: string[]): Promise; updateSanctions(userId: string, sanctions: UserSanctions): Promise; updateKakaoGraceUntil(userId: string, until: Date | null): Promise; diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index ddeb1bff..e082c9e8 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -19,6 +19,7 @@ import { } from './auth/localAccountPolicy.js'; import { openPassword, zDisplayName, zPasswordEnvelope, zRegistrationUsername } from './auth/registrationInput.js'; import { resolveEffectiveAccountIcon } from './auth/accountIconProjection.js'; +import { isGatewaySessionCurrent } from './auth/sessionValidity.js'; import { purifyGatewayNoticeHtml } from './security/gatewayNoticeHtml.js'; import type { GatewayApiContext } from './context.js'; import { listScenarioPreviews } from './scenario/scenarioCatalog.js'; @@ -144,7 +145,7 @@ export const appRouter = router({ const session = await ctx.sessions.getSession(sessionToken); if (!session) return null; const user = await ctx.users.findById(session.userId); - return user ? toPublicUser(user) : null; + return user && isGatewaySessionCurrent(session, user) ? toPublicUser(user) : null; }), lobby: router({ notice: procedure.query(async ({ ctx }) => { @@ -165,17 +166,18 @@ export const appRouter = router({ const sessionToken = (ctx.requestHeaders['x-session-token'] as string | undefined) ?? input?.sessionToken; const session = sessionToken ? await ctx.sessions.getSession(sessionToken) : null; - const profileList = await ctx.profileStatus.listLobbyProfiles({ - userId: session?.userId, - }); const user = session ? await ctx.users.findById(session.userId) : null; - if (!user) { + const currentUser = session && user && isGatewaySessionCurrent(session, user) ? user : null; + const profileList = await ctx.profileStatus.listLobbyProfiles({ + userId: currentUser?.id, + }); + if (!currentUser) { return profileList.map((profile) => ({ ...profile, localAccountPolicy: null, })); } - const specialAccessGrants = await ctx.users.listSpecialAccessGrants(user.id); + const specialAccessGrants = await ctx.users.listSpecialAccessGrants(currentUser.id); return Promise.all( profileList.map(async (profile) => { const record = await ctx.profiles.getProfile(profile.profileName); @@ -184,7 +186,7 @@ export const appRouter = router({ profileName: profile.profileName, profileMeta: record?.meta, defaultGraceDays: ctx.localAccountGraceDays, - user, + user: currentUser, specialAccessGrants, }); return { @@ -204,7 +206,7 @@ export const appRouter = router({ } const session = await ctx.sessions.getSession(sessionToken); const user = session ? await ctx.users.findById(session.userId) : null; - if (!session || !user) { + if (!session || !user || !isGatewaySessionCurrent(session, user)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session is not valid.' }); } @@ -301,7 +303,8 @@ export const appRouter = router({ const sessionToken = (ctx.requestHeaders['x-session-token'] as string | undefined) ?? input?.sessionToken; const session = sessionToken ? await ctx.sessions.getSession(sessionToken) : null; - if (!session) { + const user = session ? await ctx.users.findById(session.userId) : null; + if (!session || !user || !isGatewaySessionCurrent(session, user)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: '카카오 인증을 연결하려면 먼저 로그인해야 합니다.', @@ -351,6 +354,12 @@ export const appRouter = router({ return throwKakaoVerificationError(error); } })(); + if (await ctx.users.isKakaoIdentityRetired(profile.kakaoId)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: '영구 폐기된 카카오 계정은 다시 연결하거나 가입할 수 없습니다.', + }); + } const [existingById, existingByEmail] = await Promise.all([ ctx.users.findByOauthId('KAKAO', profile.kakaoId), ctx.users.findByEmail(profile.email), @@ -388,15 +397,28 @@ export const appRouter = router({ message: '이미 다른 계정에서 사용 중인 카카오 이메일입니다. 관리자에게 문의해 주세요.', }); } - if (localUser.oauthType === 'KAKAO' && localUser.oauthId !== profile.kakaoId) { - throw new TRPCError({ - code: 'CONFLICT', - message: '이미 다른 카카오 계정에 연결된 사용자입니다.', - }); - } const oauthInfo = oauthInfoFromToken(token, tokenIssuedAt, localUser.oauthInfo); let verified: UserRecord; - if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') { + const replacingKakao = + localUser.oauthType === 'KAKAO' && + Boolean(localUser.oauthId) && + localUser.oauthId !== profile.kakaoId; + if (replacingKakao) { + try { + verified = await ctx.users.replaceKakaoWithApprovedIdentity(localUser.id, { + oauthId: profile.kakaoId, + email: profile.email, + oauthInfo, + verifiedAt: new Date(), + }); + } catch (error) { + throw new TRPCError({ + code: 'PRECONDITION_FAILED', + message: '관리자의 카카오 계정 교체 승인이 없거나 만료되었습니다.', + cause: error, + }); + } + } else if (existingById?.id !== localUser.id && localUser.oauthType !== 'KAKAO') { try { verified = await ctx.users.linkKakao(localUser.id, { oauthId: profile.kakaoId, @@ -423,7 +445,10 @@ export const appRouter = router({ } } const refreshed = (await ctx.users.findById(verified.id)) ?? verified; - await ctx.flushPublisher.publishUserFlush(refreshed.id, 'kakao-verified'); + await ctx.flushPublisher.publishUserFlush( + refreshed.id, + replacingKakao ? 'kakao-account-replaced' : 'kakao-verified' + ); return finishKakaoLoginOrRequestPasswordSetup(ctx, refreshed, token.accessToken, 'verified'); } @@ -624,20 +649,37 @@ export const appRouter = router({ }; let linked: UserRecord; try { - linked = await ctx.users.relinkKakaoByEmail(targetUser.id, { - oauthId: oauthSession.kakaoId, - email: oauthSession.email, - oauthInfo, - verifiedAt: new Date(), - }); + const replacement = + targetUser.oauthType === 'KAKAO' && + Boolean(targetUser.oauthId) && + targetUser.oauthId !== oauthSession.kakaoId; + linked = replacement + ? await ctx.users.replaceKakaoWithApprovedIdentity(targetUser.id, { + oauthId: oauthSession.kakaoId, + email: oauthSession.email, + oauthInfo, + verifiedAt: new Date(), + }) + : await ctx.users.relinkKakaoByEmail(targetUser.id, { + oauthId: oauthSession.kakaoId, + email: oauthSession.email, + oauthInfo, + verifiedAt: new Date(), + }); } catch (error) { throw new TRPCError({ - code: 'CONFLICT', - message: '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', + code: targetUser.oauthType === 'KAKAO' ? 'PRECONDITION_FAILED' : 'CONFLICT', + message: + targetUser.oauthType === 'KAKAO' + ? '기존 카카오 계정 교체에는 관리자 승인이 필요합니다.' + : '카카오 계정 연결 상태가 변경되었습니다. 처음부터 다시 진행해 주세요.', cause: error, }); } - await ctx.flushPublisher.publishUserFlush(linked.id, 'kakao-account-relinked'); + await ctx.flushPublisher.publishUserFlush( + linked.id, + targetUser.oauthType === 'KAKAO' ? 'kakao-account-replaced' : 'kakao-account-relinked' + ); return finishKakaoLoginOrRequestPasswordSetup(ctx, linked, oauthSession.accessToken, 'login'); }), kakaoSetPassword: procedure @@ -983,7 +1025,7 @@ export const appRouter = router({ return null; } const user = await ctx.users.findById(session.userId); - if (!user) { + if (!user || !isGatewaySessionCurrent(session, user)) { return null; } return { @@ -1021,7 +1063,7 @@ export const appRouter = router({ }); } const user = await ctx.users.findById(gatewaySession.userId); - if (!user) { + if (!user || !isGatewaySessionCurrent(gatewaySession, user)) { throw new TRPCError({ code: 'UNAUTHORIZED', message: 'Session user no longer exists.', @@ -1069,6 +1111,7 @@ export const appRouter = router({ id: user.id, username: user.username, displayName: user.displayName, + ...(user.identityRevision ? { identityRevision: user.identityRevision } : {}), picture: accountIcon.picture, imageServer: accountIcon.imageServer, iconUpdatedAt: accountIcon.revision, diff --git a/app/gateway-api/test/accountIdentity.integration.test.ts b/app/gateway-api/test/accountIdentity.integration.test.ts new file mode 100644 index 00000000..c6416ccf --- /dev/null +++ b/app/gateway-api/test/accountIdentity.integration.test.ts @@ -0,0 +1,142 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { createGatewayPostgresConnector, type GatewayPrismaClient } from '@sammo-ts/infra'; + +import { createPostgresUserRepository } from '../src/auth/postgresUserRepository.js'; + +const databaseUrl = process.env.GATEWAY_RUNTIME_ACTION_DATABASE_URL; +const integration = describe.skipIf(!databaseUrl); +const firstUserId = 'f28aa8ce-6bd0-45e5-a127-056de53e3161'; +const secondUserId = '8f610116-64bd-4f18-bf7f-eae1737b227c'; +const oldKakaoId = 'account-identity-old-kakao'; +const newKakaoId = 'account-identity-new-kakao'; + +const assertDedicatedSchema = (): void => { + const expected = process.env.GATEWAY_RUNTIME_INTEGRATION_SCHEMA; + const actual = databaseUrl ? new URL(databaseUrl).searchParams.get('schema') : null; + if (!expected || !expected.endsWith('_gateway_runtime_integration') || actual !== expected) { + throw new Error('Refusing to mutate a Gateway database outside the runner-owned integration schema.'); + } +}; + +integration('account identity PostgreSQL transaction', () => { + let db: GatewayPrismaClient; + let closeDb: (() => Promise) | undefined; + + beforeAll(async () => { + assertDedicatedSchema(); + const connector = createGatewayPostgresConnector({ url: databaseUrl! }); + await connector.connect(); + db = connector.prisma; + closeDb = () => connector.disconnect(); + await db.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } }); + await db.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } }); + await db.appUser.createMany({ + data: [ + { + id: firstUserId, + loginId: 'identity-integration-one', + displayName: '이름통합첫째', + passwordHash: 'not-used', + passwordSalt: 'not-used', + roles: ['user'], + sanctions: {}, + oauthType: 'KAKAO', + oauthId: oldKakaoId, + email: 'identity-one@example.com', + }, + { + id: secondUserId, + loginId: 'identity-integration-two', + displayName: '이름통합둘째', + passwordHash: 'not-used', + passwordSalt: 'not-used', + roles: ['user'], + sanctions: {}, + oauthType: 'KAKAO', + oauthId: 'account-identity-second-kakao', + email: 'identity-two@example.com', + }, + ], + }); + }); + + afterAll(async () => { + await db?.retiredKakaoIdentity.deleteMany({ where: { oauthId: { in: [oldKakaoId, newKakaoId] } } }); + await db?.appUser.deleteMany({ where: { id: { in: [firstUserId, secondUserId] } } }); + await closeDb?.(); + }); + + it('atomically retires the former Kakao ID and rejects its reuse', async () => { + const users = createPostgresUserRepository(db); + const verifiedAt = new Date('2026-08-24T12:00:00.000Z'); + await users.setKakaoReplacementApproval(firstUserId, { + until: new Date('2026-08-24T13:00:00.000Z'), + approvedByUserId: 'integration-admin', + reason: '통합 테스트 교체 승인', + }); + const replaced = await users.replaceKakaoWithApprovedIdentity(firstUserId, { + oauthId: newKakaoId, + email: 'identity-new@example.com', + oauthInfo: { accessToken: 'not-a-real-token' }, + verifiedAt, + }); + + expect(replaced).toMatchObject({ + oauthId: newKakaoId, + email: 'identity-new@example.com', + authRevision: 1, + sessionRevokedBefore: verifiedAt.toISOString(), + kakaoReplacementApprovedUntil: undefined, + }); + await expect(users.isKakaoIdentityRetired(oldKakaoId)).resolves.toBe(true); + await expect( + users.linkKakao(secondUserId, { + oauthId: oldKakaoId, + email: 'identity-two@example.com', + oauthInfo: {}, + verifiedAt, + }) + ).rejects.toThrow('permanently retired'); + + await users.setKakaoReplacementApproval(secondUserId, { + until: new Date('2026-08-24T13:00:00.000Z'), + approvedByUserId: 'integration-admin', + reason: '폐기 ID 재사용 거부', + }); + await expect( + users.replaceKakaoWithApprovedIdentity(secondUserId, { + oauthId: oldKakaoId, + email: 'identity-two-new@example.com', + oauthInfo: {}, + verifiedAt, + }) + ).rejects.toThrow(); + await expect(users.findById(secondUserId)).resolves.toMatchObject({ + oauthId: 'account-identity-second-kakao', + email: 'identity-two@example.com', + }); + }); + + it('changes login ID and nickname together while preserving uniqueness', async () => { + const users = createPostgresUserRepository(db); + const before = await users.findById(firstUserId); + const updated = await users.updateIdentity(firstUserId, { + username: 'identity-integration-renamed', + displayName: '이름통합변경', + changedAt: new Date('2026-08-24T14:00:00.000Z'), + }); + expect(updated).toMatchObject({ + username: 'identity-integration-renamed', + displayName: '이름통합변경', + }); + expect(Date.parse(updated.identityRevision ?? '')).toBeGreaterThan(Date.parse(before?.identityRevision ?? '')); + await expect( + users.updateIdentity(firstUserId, { + username: 'identity-integration-two', + displayName: '이름통합변경', + changedAt: new Date('2026-08-24T14:00:01.000Z'), + }) + ).rejects.toThrow(); + }); +}); diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index b466c631..e0659d90 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -77,7 +77,13 @@ const buildCaller = async ( const operationRecords = new Map>>(); if (options.initialOperation) operationRecords.set(options.initialOperation.id, options.initialOperation); const createdRuntimeActions: Array> = []; - const flushes: Array<{ userId: string; reason?: string; iconRevision?: string }> = []; + const flushes: Array<{ + userId: string; + reason?: string; + iconRevision?: string; + displayName?: string; + identityRevision?: string; + }> = []; const updatedStatuses: GatewayProfileRecord['status'][] = []; const updatedMetas: Record[] = []; const auditEvents: AdminAuditEventRecord[] = []; @@ -238,6 +244,8 @@ const buildCaller = async ( userId, reason, ...(metadata?.iconRevision ? { iconRevision: metadata.iconRevision } : {}), + ...(metadata?.displayName ? { displayName: metadata.displayName } : {}), + ...(metadata?.identityRevision ? { identityRevision: metadata.identityRevision } : {}), }); }, }, @@ -1946,6 +1954,64 @@ describe('Gateway administrator account controls', () => { expect(harness.flushes).toContainEqual({ userId: target.id, reason: 'admin-kakao-grace-updated' }); }); + it('approves a time-bounded Kakao replacement only for an already linked account', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'kakao-replacement-target', + password: 'secretpass', + displayName: '교체대상', + oauth: { type: 'KAKAO', id: 'former-kakao-id', email: 'former@example.com', info: {} }, + }); + const until = new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + + await expect( + harness.caller.admin.users.setKakaoReplacementApproval({ + userId: target.id, + until, + reason: '새 Kakao 계정 본인 확인 예정', + }) + ).resolves.toEqual({ kakaoReplacementApprovedUntil: until }); + expect(await harness.users.findById(target.id)).toMatchObject({ + kakaoReplacementApprovedUntil: until, + kakaoReplacementApprovedByUserId: harness.admin.id, + kakaoReplacementReason: '새 Kakao 계정 본인 확인 예정', + }); + + await expect( + harness.caller.admin.users.setKakaoReplacementApproval({ + userId: target.id, + until: new Date(Date.now() + 8 * 24 * 60 * 60 * 1000).toISOString(), + reason: '기간 상한 검증', + }) + ).rejects.toMatchObject({ code: 'BAD_REQUEST' }); + }); + + it('changes login ID and nickname while publishing a current-general projection', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'rename-target', + password: 'secretpass', + displayName: '이전닉네임', + }); + + const result = await harness.caller.admin.users.updateIdentity({ + userId: target.id, + username: 'renamed-target', + displayName: '새닉네임', + reason: '사용자 본인 요청 확인', + }); + + expect(result).toMatchObject({ username: 'renamed-target', displayName: '새닉네임' }); + expect(await harness.users.findByUsername('rename-target')).toBeNull(); + expect(await harness.users.findByUsername('renamed-target')).toMatchObject({ displayName: '새닉네임' }); + expect(harness.flushes.at(-1)).toEqual({ + userId: target.id, + reason: 'admin-account-identity-updated', + displayName: '새닉네임', + identityRevision: result.identityRevision, + }); + }); + it('grants and revokes profile-scoped recovery access with an audit trail', async () => { const harness = await buildCaller(unusedCreateOperation); const target = await harness.users.createUser({ @@ -2043,6 +2109,8 @@ describe('Gateway administrator account controls', () => { displayName: 'Root Target', }); await harness.users.updateRoles(target.id, ['user', 'admin']); + target.oauthType = 'KAKAO'; + target.oauthId = 'protected-root-kakao-id'; await expect( harness.caller.admin.users.updateSanctions({ @@ -2051,7 +2119,25 @@ describe('Gateway administrator account controls', () => { reason: '루트 계정 보호 확인', }) ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + harness.caller.admin.users.updateIdentity({ + userId: target.id, + username: 'root-target-renamed', + displayName: '변경 금지 대상', + reason: '루트 계정 보호 확인', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + await expect( + harness.caller.admin.users.setKakaoReplacementApproval({ + userId: target.id, + until: new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(), + reason: '루트 계정 보호 확인', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); expect((await harness.users.findById(target.id))?.sanctions).toEqual({}); + const protectedUser = await harness.users.findByUsername('root-target'); + expect(protectedUser).toMatchObject({ displayName: 'Root Target' }); + expect(protectedUser?.kakaoReplacementApprovedUntil).toBeUndefined(); }); it('keeps the global audit feed behind its dedicated capability', async () => { diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index 0dc6fe40..db8ead30 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -675,6 +675,11 @@ describe('gateway auth flow', () => { }); emailOwner.passwordResetRequired = true; await users.markKakaoTalkVerified(emailOwner.id, new Date(Date.now() + 60_000)); + await users.setKakaoReplacementApproval(emailOwner.id, { + until: new Date(Date.now() + 60_000), + approvedByUserId: 'admin-user-id', + reason: '이메일 보존 계정의 교체 승인', + }); kakaoProfile.id = 'different-kakao-id'; const start = await caller.auth.kakaoStart({ mode: 'login' }); @@ -701,12 +706,13 @@ describe('gateway auth flow', () => { expect(passwordSet.status).toBe('otp'); expect(sentTalkMessages).toHaveLength(1); expect(await users.findByOauthId('KAKAO', 'original-kakao-id')).toBeNull(); + expect(await users.isKakaoIdentityRetired('original-kakao-id')).toBe(true); expect(await users.findByOauthId('KAKAO', 'different-kakao-id')).toMatchObject({ id: emailOwner.id, username: 'email-owner', email: 'tester@example.com', }); - expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-relinked'); + expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'kakao-account-replaced'); expect(flushPublisher.publishUserFlush).toHaveBeenCalledWith(emailOwner.id, 'password-changed'); }); @@ -880,6 +886,44 @@ describe('gateway auth flow', () => { }); }); + it('replaces an approved Kakao identity, retires the old ID, and invalidates older sessions', async () => { + const { caller, users, sessions, kakaoProfile, setSessionHeader } = buildCaller({ + kakaoId: 'new-kakao-id', + kakaoEmail: 'new-kakao@example.com', + }); + const user = await users.createUser({ + username: 'approved-replacement-user', + password: 'replacement-password', + oauth: { type: 'KAKAO', id: 'old-kakao-id', email: 'old-kakao@example.com', info: {} }, + }); + const oldSession = await sessions.createSession(user); + setSessionHeader(oldSession.sessionToken); + await users.setKakaoReplacementApproval(user.id, { + until: new Date(Date.now() + 60_000), + approvedByUserId: 'admin-user-id', + reason: '사용자 본인 확인 완료', + }); + + const start = await caller.auth.kakaoStart({ mode: 'verify', sessionToken: oldSession.sessionToken }); + const result = await caller.auth.kakaoExchange({ code: 'oauth-code', state: start.state }); + expect(result).toMatchObject({ status: 'otp', successStatus: 'verified' }); + expect(await users.findById(user.id)).toMatchObject({ + oauthId: 'new-kakao-id', + email: 'new-kakao@example.com', + kakaoReplacementApprovedUntil: undefined, + authRevision: 1, + }); + await expect(users.isKakaoIdentityRetired('old-kakao-id')).resolves.toBe(true); + await expect(caller.auth.me({ sessionToken: oldSession.sessionToken })).resolves.toBeNull(); + + kakaoProfile.id = 'old-kakao-id'; + kakaoProfile.email = 'old-kakao@example.com'; + const retiredStart = await caller.auth.kakaoStart({ mode: 'login' }); + await expect( + caller.auth.kakaoExchange({ code: 'oauth-code', state: retiredStart.state }) + ).rejects.toMatchObject({ code: 'FORBIDDEN', message: expect.stringContaining('영구 폐기') }); + }); + it('synchronizes a changed email by stable Kakao ID during Kakao login', async () => { const { caller, users, kakaoProfile } = buildCaller({ kakaoId: 'stable-kakao-id', diff --git a/app/gateway-api/test/releaseManifest.test.ts b/app/gateway-api/test/releaseManifest.test.ts index 97f2ff3c..818a2ee0 100644 --- a/app/gateway-api/test/releaseManifest.test.ts +++ b/app/gateway-api/test/releaseManifest.test.ts @@ -38,7 +38,7 @@ describe('readReleaseManifest', () => { await expect(readReleaseManifest(workspaceRoot)).resolves.toMatchObject({ controllerProtocol: RELEASE_CONTROLLER_PROTOCOL, - gatewaySchemaHead: '20260824090000_allow_interim_profile_deploy', + gatewaySchemaHead: '20260824120000_add_account_identity_management', gameSchemaHead: '20260824080000_vote_utc_wall_timestamps', }); }); diff --git a/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts b/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts new file mode 100644 index 00000000..25024599 --- /dev/null +++ b/app/gateway-frontend/e2e/account-kakao-replacement.spec.ts @@ -0,0 +1,107 @@ +import { expect, test, type Page, type Route } from '@playwright/test'; +import { writeFile } from 'node:fs/promises'; + +const response = (data: unknown) => ({ result: { data } }); +const operationNames = (route: Route): string[] => { + const url = new URL(route.request().url()); + return decodeURIComponent(url.pathname.slice(url.pathname.lastIndexOf('/trpc/') + 6)).split(','); +}; + +const installFixture = async (page: Page) => { + const requests: Array<{ operation: string; body: string }> = []; + await page.addInitScript(() => { + window.localStorage.setItem('sammo-session-token', 'kakao-replacement-session'); + }); + await page.route('**/gateway/api/trpc/**', async (route) => { + const body = route.request().postData() ?? ''; + const results = operationNames(route).map((operation) => { + requests.push({ operation, body }); + if (operation === 'account.get') { + return response({ + id: 'replacement-user', + username: 'replacement-user', + displayName: '교체 사용자', + roles: ['user'], + oauthType: 'KAKAO', + email: 'replacement@example.test', + createdAt: '2026-08-01T00:00:00.000Z', + iconUrl: null, + icons: [], + preferredPicture: 'default.jpg', + maxActiveIcons: 5, + nextUploadAt: null, + nextRetireAt: null, + thirdPartyUse: false, + deleteAfter: null, + kakaoReplacementApprovedUntil: new Date(Date.now() + 86_400_000).toISOString(), + }); + } + if (operation === 'account.notifications.get') { + return response({ + capability: { enabled: false, publicKey: null }, + eventTypes: [], + profiles: [], + preferences: [], + subscriptionCount: 0, + currentDeviceSubscribed: false, + }); + } + if (operation === 'auth.kakaoStart') { + return response({ + authUrl: `${new URL(route.request().url()).origin}/gateway/account?replacement=started`, + }); + } + throw new Error(`Unhandled account replacement fixture operation: ${operation}`); + }); + const isBatch = new URL(route.request().url()).searchParams.get('batch') === '1'; + await route.fulfill({ + status: 200, + contentType: 'application/json', + body: JSON.stringify(isBatch ? results : results[0]), + }); + }); + return requests; +}; + +test('starts an approved Kakao replacement with explicit permanent-retirement confirmation', async ({ + page, +}, testInfo) => { + const requests = await installFixture(page); + let confirmation = ''; + page.on('dialog', async (dialog) => { + confirmation = dialog.message(); + await dialog.accept(); + }); + + await page.goto('account'); + const button = page.getByRole('button', { name: '새 카카오 계정으로 교체' }); + await expect(button).toBeVisible(); + await expect(page.getByText(/교체 승인 .*까지/)).toBeVisible(); + const initialBackground = await button.evaluate((element) => getComputedStyle(element).backgroundColor); + await button.hover(); + await expect + .poll(() => button.evaluate((element) => getComputedStyle(element).backgroundColor)) + .not.toBe(initialBackground); + await button.focus(); + await expect(button).toBeFocused(); + await page.screenshot({ path: testInfo.outputPath('account-kakao-replacement-desktop.png'), fullPage: true }); + + await button.click(); + await expect(page).toHaveURL(/replacement=started/); + expect(confirmation).toContain('기존 카카오 계정은 영구 폐기'); + const startRequest = requests.find(({ operation }) => operation === 'auth.kakaoStart'); + expect(startRequest?.body).toContain('verify'); + expect(startRequest?.body).toContain('kakao-replacement-session'); + + await page.setViewportSize({ width: 500, height: 844 }); + const mobileButton = page.getByRole('button', { name: '새 카카오 계정으로 교체' }); + await expect(mobileButton).toBeVisible(); + const geometry = await mobileButton.evaluate((element) => { + const rect = element.getBoundingClientRect(); + return { left: rect.left, right: rect.right, width: rect.width, viewportWidth: window.innerWidth }; + }); + expect(geometry.left).toBeGreaterThanOrEqual(0); + expect(geometry.right).toBeLessThanOrEqual(geometry.viewportWidth); + await writeFile(testInfo.outputPath('account-kakao-replacement-mobile-geometry.json'), JSON.stringify(geometry)); + await page.screenshot({ path: testInfo.outputPath('account-kakao-replacement-mobile.png'), fullPage: true }); +}); diff --git a/app/gateway-frontend/e2e/admin-account-controls.spec.ts b/app/gateway-frontend/e2e/admin-account-controls.spec.ts index b2ac3bd1..49cae7fb 100644 --- a/app/gateway-frontend/e2e/admin-account-controls.spec.ts +++ b/app/gateway-frontend/e2e/admin-account-controls.spec.ts @@ -11,6 +11,9 @@ const installFixture = async (page: Page) => { const requests: Array<{ operation: string; body: unknown }> = []; let deleteAfter: string | null = null; let graceUntil: string | null = null; + let username = 'target'; + let displayName = '대상 사용자'; + let kakaoReplacementApprovedUntil: string | null = null; let specialGrants: Array> = []; const auditHistory = [ { @@ -74,10 +77,10 @@ const installFixture = async (page: Page) => { users: [ { id: 'target-user', - username: 'target', - displayName: '대상 사용자', + username, + displayName, email: 'target@example.test', - oauthType: 'NONE', + oauthType: 'KAKAO', roles: ['user'], hasActiveSanction: false, deleteAfter, @@ -111,11 +114,13 @@ const installFixture = async (page: Page) => { if (operation === 'admin.users.lookup') { return response({ id: 'target-user', - username: 'target', - displayName: '대상 사용자', + username, + displayName, roles: ['user'], sanctions: {}, - oauthType: 'NONE', + oauthType: 'KAKAO', + oauthId: 'fixture-kakao-stable-id', + kakaoReplacementApprovedUntil, kakaoGraceStartedAt: '2026-07-20T00:00:00.000Z', kakaoGraceUntil: graceUntil, deleteAfter, @@ -164,6 +169,27 @@ const installFixture = async (page: Page) => { }); return response({ kakaoGraceUntil: graceUntil }); } + if (operation === 'admin.users.updateIdentity') { + username = 'target-renamed'; + displayName = '변경된 대상'; + auditHistory.unshift({ + ...auditHistory[0], + id: 'audit-identity', + action: 'admin.users.updateIdentity', + reason: '고객 본인 확인 완료', + }); + return response({ username, displayName, identityRevision: '2026-08-24T12:00:00.000Z' }); + } + if (operation === 'admin.users.setKakaoReplacementApproval') { + kakaoReplacementApprovedUntil = '2026-08-26T00:00:00.000Z'; + auditHistory.unshift({ + ...auditHistory[0], + id: 'audit-kakao-replacement', + action: 'admin.users.setKakaoReplacementApproval', + reason: '기존 단말 분실 교체', + }); + return response({ kakaoReplacementApprovedUntil }); + } if (operation === 'admin.users.grantSpecialAccess') { specialGrants = [ { @@ -219,6 +245,18 @@ test('operates OAuth grace and scheduled deletion with reasoned audit history', await expect(page.getByText('Kakao 인증: 미완료')).toBeVisible(); await expect(page.getByRole('navigation', { name: '사용자 관리 기능' })).toBeVisible(); await expect(page.getByRole('heading', { name: '비밀번호 리셋' })).toBeHidden(); + await expect(page.getByRole('heading', { name: 'ID · 닉네임 변경' })).toBeVisible(); + await page.getByLabel('로그인 ID').fill('target-renamed'); + await page.getByLabel('닉네임').fill('변경된 대상'); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('고객 본인 확인 완료'); + await page.getByRole('button', { name: 'ID · 닉네임 변경', exact: true }).click(); + await expect(page.getByText('ID와 닉네임을 변경했습니다.').first()).toBeVisible(); + await page.getByLabel('Kakao 계정 교체 승인 만료 시각').fill('2026-08-26T00:00'); + await page.getByPlaceholder('권한·제재·복구·탈퇴 조치 사유 (필수)').fill('기존 단말 분실 교체'); + await page.getByRole('button', { name: '교체 승인', exact: true }).click(); + await expect(page.getByText('새 카카오 계정 교체를 승인했습니다.').first()).toBeVisible(); + expect(requests.some(({ operation }) => operation === 'admin.users.updateIdentity')).toBe(true); + expect(requests.some(({ operation }) => operation === 'admin.users.setKakaoReplacementApproval')).toBe(true); await page.getByRole('button', { name: /접근 · 권한/ }).click(); await expect(page.getByRole('option', { name: /Profile 전체 운영/ })).toHaveCount(0); await expect(page.getByRole('option', { name: /Profile 실행 관리/ })).toHaveCount(1); diff --git a/app/gateway-frontend/e2e/playwright.config.mjs b/app/gateway-frontend/e2e/playwright.config.mjs index 93988a7c..53957e96 100644 --- a/app/gateway-frontend/e2e/playwright.config.mjs +++ b/app/gateway-frontend/e2e/playwright.config.mjs @@ -11,6 +11,7 @@ export default defineConfig({ 'server-operations.spec.ts', 'admin-runtime-actions.spec.ts', 'admin-account-controls.spec.ts', + 'account-kakao-replacement.spec.ts', 'lobby-admin-navigation.spec.ts', 'lobby-game-auth.spec.ts', 'logout.spec.ts', diff --git a/app/gateway-frontend/src/views/AccountView.vue b/app/gateway-frontend/src/views/AccountView.vue index 12e10da6..23422ef3 100644 --- a/app/gateway-frontend/src/views/AccountView.vue +++ b/app/gateway-frontend/src/views/AccountView.vue @@ -86,6 +86,11 @@ const currentProfile = computed(() => const notificationEventTypes = computed( () => (notificationState.value?.eventTypes ?? []) as readonly WebPushEventType[] ); +const kakaoReplacementApproved = computed( + () => + Boolean(account.value?.kakaoReplacementApprovedUntil) && + new Date(account.value!.kakaoReplacementApprovedUntil!).getTime() > Date.now() +); const sessionToken = (): string | null => window.localStorage.getItem('sammo-session-token'); @@ -312,6 +317,22 @@ const changePassword = async (): Promise => { }); }; +const startKakaoReplacement = async (): Promise => { + if ( + !window.confirm( + '새 카카오 계정 연결이 끝나면 기존 카카오 계정은 영구 폐기되어 다시 가입하거나 연결할 수 없습니다. 계속하시겠습니까?' + ) + ) { + return; + } + await runAction(async () => { + const token = sessionToken(); + if (!token) throw new Error('로그인이 필요합니다.'); + const result = await trpc.auth.kakaoStart.query({ mode: 'verify', sessionToken: token }); + window.location.assign(result.authUrl); + }); +}; + const disallowThirdPartyUse = async (): Promise => { await runAction(async () => { const token = sessionToken(); @@ -639,7 +660,21 @@ onBeforeUnmount(() => { 인증 방식 - {{ account.oauthType }} + + {{ account.oauthType }} + + + diff --git a/app/gateway-frontend/src/views/AdminView.vue b/app/gateway-frontend/src/views/AdminView.vue index 65e3cec8..27b13bb8 100644 --- a/app/gateway-frontend/src/views/AdminView.vue +++ b/app/gateway-frontend/src/views/AdminView.vue @@ -99,6 +99,8 @@ type AdminUser = { kakaoVerifiedAt?: string; kakaoGraceStartedAt: string; kakaoGraceUntil?: string; + identityRevision?: string; + kakaoReplacementApprovedUntil?: string; profileIconResetAt?: string; deleteAfter?: string; createdAt: string; @@ -289,6 +291,19 @@ type AdminClient = { kakaoGraceUntil: string | null; }>; }; + setKakaoReplacementApproval: { + mutate: (input: { userId: string; until: string | null; reason: string }) => Promise<{ + kakaoReplacementApprovedUntil: string | null; + }>; + }; + updateIdentity: { + mutate: (input: { + userId: string; + username: string; + displayName: string; + reason: string; + }) => Promise<{ username: string; displayName: string; identityRevision?: string }>; + }; grantSpecialAccess: { mutate: (input: { userId: string; @@ -544,6 +559,11 @@ const localAccountForm = ref({ const passwordInput = ref(''); const passwordResult = ref(''); const passwordStatus = ref(''); +const identityUsername = ref(''); +const identityDisplayName = ref(''); +const identityStatus = ref(''); +const kakaoReplacementUntil = ref(''); +const kakaoReplacementStatus = ref(''); const rolesInput = ref(''); const rolesMode = ref<'set' | 'grant' | 'revoke'>('grant'); @@ -624,6 +644,8 @@ const actionFeedback = [ noticeStatus, userError, kakaoGraceStatus, + identityStatus, + kakaoReplacementStatus, specialAccessStatus, passwordStatus, rolesStatus, @@ -1025,6 +1047,11 @@ const lookupUser = async () => { return; } userResult.value = result; + identityUsername.value = result.username; + identityDisplayName.value = result.displayName; + kakaoReplacementUntil.value = result.kakaoReplacementApprovedUntil + ? toLocalInputValue(result.kakaoReplacementApprovedUntil) + : toLocalInputValue(new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString()); const [grace, history] = await Promise.all([ adminClient.users.getKakaoGracePolicies.query({ userId: result.id }), adminClient.users.listHistory.query({ userId: result.id, limit: 50 }), @@ -1137,6 +1164,56 @@ const updateKakaoGrace = async (clear = false) => { } }; +const updateUserIdentity = async () => { + if (!userResult.value) return; + const reason = requireUserActionReason(); + if (!reason) return; + identityStatus.value = ''; + try { + const result = await adminClient.users.updateIdentity.mutate({ + userId: userResult.value.id, + username: identityUsername.value, + displayName: identityDisplayName.value, + reason, + }); + userResult.value = { ...userResult.value, ...result }; + identityUsername.value = result.username; + identityDisplayName.value = result.displayName; + identityStatus.value = 'ID와 닉네임을 변경했습니다.'; + await Promise.all([refreshUserHistory(), loadUserDirectory()]); + } catch { + identityStatus.value = 'ID 또는 닉네임 변경에 실패했습니다.'; + } +}; + +const setKakaoReplacementApproval = async (clear = false) => { + if (!userResult.value) return; + const reason = requireUserActionReason(); + if (!reason) return; + const until = clear ? null : (serverDateTimeInputToIso(kakaoReplacementUntil.value) ?? null); + if (!clear && !until) { + kakaoReplacementStatus.value = '올바른 교체 승인 만료 시각을 입력하세요.'; + return; + } + try { + const result = await adminClient.users.setKakaoReplacementApproval.mutate({ + userId: userResult.value.id, + until, + reason, + }); + userResult.value = { + ...userResult.value, + kakaoReplacementApprovedUntil: result.kakaoReplacementApprovedUntil ?? undefined, + }; + kakaoReplacementStatus.value = result.kakaoReplacementApprovedUntil + ? '새 카카오 계정 교체를 승인했습니다.' + : '카카오 계정 교체 승인을 해제했습니다.'; + await refreshUserHistory(); + } catch { + kakaoReplacementStatus.value = '카카오 계정 교체 승인 변경에 실패했습니다.'; + } +}; + const grantSpecialAccess = async () => { if (!userResult.value) return; const reason = requireUserActionReason(); @@ -1608,6 +1685,10 @@ onMounted(() => {
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
+
+ Kakao 교체 승인: + {{ formatServerDateTime(userResult.kakaoReplacementApprovedUntil) }}까지 +
탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
@@ -1704,6 +1785,75 @@ onMounted(() => { +
+

ID · 닉네임 변경

+

+ 사용자는 직접 바꿀 수 없습니다. 닉네임은 현재 장수에 반영되며 과거 장수와 명예의 전당의 당시 + 기록은 유지됩니다. +

+
+ + + + + +
+
{{ identityStatus }}
+
+ +
+

Kakao 계정 영구 교체

+

+ 승인 뒤 사용자가 계정 관리에서 새 Kakao 계정의 소유권을 직접 증명합니다. 교체가 완료되면 + 기존 Kakao stable ID는 영구 폐기되어 로그인·재가입·재연결에 쓸 수 없습니다. +

+ +
+ + +
+
{{ kakaoReplacementStatus }}
+
+