merge: 관리자 계정 식별자와 카카오 영구 교체를 반영한다

This commit is contained in:
2026-08-24 23:26:58 +00:00
34 changed files with 1496 additions and 143 deletions
+2
View File
@@ -3,6 +3,8 @@ export interface GatewayUserFlushEvent {
flushedAt: string;
reason?: string;
iconRevision?: string;
displayName?: string;
identityRevision?: string;
}
export interface FlushStore {
+9
View File
@@ -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') {
+6 -1
View File
@@ -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');
}
@@ -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<void> => {
await turnDaemon.sendCommand({
type: 'adjustGeneralIdentity',
requestId: `general:adjustIdentity:${userId}:${identityRevision}`,
userId,
displayName,
identityRevision,
});
};
export const createAccountIdentityFlushHandler =
(turnDaemon: TurnDaemonTransport) =>
async (event: GatewayUserFlushEvent): Promise<void> => {
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);
};
@@ -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}`);
});
});
@@ -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,
@@ -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<TurnDaemonCommand, { type: 'adjustGeneralIdentity' }>
): Promise<TurnDaemonCommandResult> {
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<TurnDaemonCommand, { type: 'shiftSchedule' }>
@@ -3120,6 +3177,8 @@ export const createTurnDaemonCommandHandler = (options: {
handleInheritanceAction(ctx, command as Extract<TurnDaemonCommand, { type: 'inheritanceAction' }>),
adjustGeneralIcon: (command) =>
handleAdjustGeneralIcon(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIcon' }>),
adjustGeneralIdentity: (command) =>
handleAdjustGeneralIdentity(ctx, command as Extract<TurnDaemonCommand, { type: 'adjustGeneralIdentity' }>),
joinCreateGeneral: (command) =>
handleJoinCreateGeneral(ctx, command as Extract<TurnDaemonCommand, { type: 'joinCreateGeneral' }>),
npcPossessGeneral: (command) =>
@@ -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' });
});
});
+3 -1
View File
@@ -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)),
+82 -1
View File
@@ -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<AdminAuthContex
});
}
const user = await ctx.users.findById(session.userId);
if (!user) {
if (!user || !isGatewaySessionCurrent(session, user)) {
throw new TRPCError({
code: 'UNAUTHORIZED',
message: 'User not found.',
@@ -765,6 +767,8 @@ export const adminRouter = router({
oauthType: user.oauthType,
oauthId: user.oauthId,
email: user.email,
identityRevision: user.identityRevision,
kakaoReplacementApprovedUntil: user.kakaoReplacementApprovedUntil,
kakaoVerifiedAt: user.kakaoVerifiedAt,
kakaoGraceStartedAt: user.kakaoGraceStartedAt,
kakaoGraceUntil: user.kakaoGraceUntil,
@@ -921,6 +925,83 @@ export const adminRouter = router({
await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-kakao-grace-updated');
return { kakaoGraceUntil: until?.toISOString() ?? null };
}),
setKakaoReplacementApproval: userAdminProcedure
.input(
z.object({
userId: z.string().min(1),
until: z.string().datetime().nullable(),
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.' });
}
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 }) =>
+14 -2
View File
@@ -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<void>;
publishUserFlush(
userId: string,
reason?: string,
metadata?: { iconRevision?: string; displayName?: string; identityRevision?: string }
): Promise<void>;
}
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<void> {
async publishUserFlush(
userId: string,
reason?: string,
metadata?: { iconRevision?: string; displayName?: string; identityRevision?: string }
): Promise<void> {
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));
}
@@ -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);
@@ -28,6 +28,7 @@ export const createInMemoryUserRepository = (hasher: PasswordHasher = createPass
const usersByName = new Map<string, UserRecord>();
const usersByOauthId = new Map<string, UserRecord>();
const usersByEmail = new Map<string, UserRecord>();
const retiredKakaoIds = new Set<string>();
const iconsById = new Map<string, UserIconRecord>();
const specialAccessGrantsById = new Map<string, SpecialAccountAccessGrantRecord>();
@@ -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<boolean> {
return retiredKakaoIds.has(oauthId);
},
async setKakaoReplacementApproval(userId, input): Promise<UserRecord> {
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<UserRecord> {
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<UserRecord> {
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<void> {
for (const user of usersByName.values()) {
if (user.id === userId) {
@@ -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<UserOAuthInfo>(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<boolean> {
@@ -323,40 +348,188 @@ export const createPostgresUserRepository = (
return mapUser(row);
},
async linkKakao(userId, input): Promise<UserRecord> {
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<UserRecord> {
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<boolean> {
return (await prisma.retiredKakaoIdentity.count({ where: { oauthId } })) > 0;
},
async setKakaoReplacementApproval(userId, input): Promise<UserRecord> {
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<UserRecord> {
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<UserRecord> {
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<void> {
await prisma.appUser.update({
where: { id: userId },
@@ -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);
@@ -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;
}
@@ -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;
};
@@ -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<UserRecord>;
isKakaoIdentityRetired(oauthId: string): Promise<boolean>;
setKakaoReplacementApproval(
userId: string,
input: { until: Date | null; approvedByUserId: string; reason: string }
): Promise<UserRecord>;
replaceKakaoWithApprovedIdentity(
userId: string,
input: {
oauthId: string;
email: string;
oauthInfo: UserOAuthInfo;
verifiedAt: Date;
}
): Promise<UserRecord>;
updateIdentity(
userId: string,
input: { username: string; displayName: string; changedAt: Date }
): Promise<UserRecord>;
updateRoles(userId: string, roles: string[]): Promise<void>;
updateSanctions(userId: string, sanctions: UserSanctions): Promise<void>;
updateKakaoGraceUntil(userId: string, until: Date | null): Promise<void>;
+71 -28
View File
@@ -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,
@@ -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<void>) | 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();
});
});
+87 -1
View File
@@ -77,7 +77,13 @@ const buildCaller = async (
const operationRecords = new Map<string, Awaited<ReturnType<GatewayProfileRepository['createOperation']>>>();
if (options.initialOperation) operationRecords.set(options.initialOperation.id, options.initialOperation);
const createdRuntimeActions: Array<Record<string, unknown>> = [];
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<string, unknown>[] = [];
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 () => {
+45 -1
View File
@@ -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',
+1 -1
View File
@@ -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',
});
});
@@ -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 });
});
@@ -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<Record<string, unknown>> = [];
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);
@@ -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',
+36 -1
View File
@@ -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<void> => {
});
};
const startKakaoReplacement = async (): Promise<void> => {
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<void> => {
await runAction(async () => {
const token = sessionToken();
@@ -639,7 +660,21 @@ onBeforeUnmount(() => {
</tr>
<tr>
<th class="legacy-bg1">인증 방식</th>
<td colspan="5">{{ account.oauthType }}</td>
<td colspan="5">
{{ account.oauthType }}
<template v-if="account.kakaoReplacementApprovedUntil">
· 교체 승인 {{ formatServerDateTime(account.kakaoReplacementApprovedUntil) }}까지
</template>
<button
v-if="kakaoReplacementApproved"
class="skin-button compact"
type="button"
:disabled="busy"
@click="startKakaoReplacement"
>
카카오 계정으로 교체
</button>
</td>
</tr>
<tr>
<th class="legacy-bg1"></th>
@@ -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(() => {
<div v-if="userResult.kakaoGraceUntil" class="text-xs text-amber-300">
관리자 유예: {{ formatServerDateTime(userResult.kakaoGraceUntil) }}까지
</div>
<div v-if="userResult.kakaoReplacementApprovedUntil" class="text-xs text-amber-300">
Kakao 교체 승인:
{{ formatServerDateTime(userResult.kakaoReplacementApprovedUntil) }}까지
</div>
<div v-if="userResult.deleteAfter" class="text-xs text-red-300">
탈퇴 예약: {{ formatServerDateTime(userResult.deleteAfter) }}
</div>
@@ -1704,6 +1785,75 @@ onMounted(() => {
</div>
</div>
<div
v-if="userWorkspaceSection === 'account' && hasUser"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">ID · 닉네임 변경</h4>
<p class="text-xs text-zinc-400">
사용자는 직접 바꿀 없습니다. 닉네임은 현재 장수에 반영되며 과거 장수와 명예의 전당의 당시
기록은 유지됩니다.
</p>
<div class="grid gap-2">
<label class="text-xs text-zinc-400" for="admin-identity-username">로그인 ID</label>
<input
id="admin-identity-username"
v-model="identityUsername"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<label class="text-xs text-zinc-400" for="admin-identity-display-name">닉네임</label>
<input
id="admin-identity-display-name"
v-model="identityDisplayName"
type="text"
class="bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
/>
<button
type="button"
class="bg-blue-700 hover:bg-blue-600 text-white font-semibold px-4 py-2 rounded"
@click="updateUserIdentity"
>
ID · 닉네임 변경
</button>
</div>
<div class="text-xs text-zinc-500">{{ identityStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'account' && hasUser && userResult?.oauthType === 'KAKAO'"
class="bg-zinc-900 border border-red-900/70 rounded-lg p-5 space-y-4"
>
<h4 class="text-base font-semibold">Kakao 계정 영구 교체</h4>
<p class="text-xs text-zinc-400">
승인 사용자가 계정 관리에서 Kakao 계정의 소유권을 직접 증명합니다. 교체가 완료되면
기존 Kakao stable ID는 영구 폐기되어 로그인·재가입·재연결에 없습니다.
</p>
<input
v-model="kakaoReplacementUntil"
type="datetime-local"
class="w-full bg-zinc-950 border border-zinc-700 rounded px-3 py-2 text-sm text-white"
aria-label="Kakao 계정 교체 승인 만료 시각"
/>
<div class="flex flex-col gap-2 sm:flex-row">
<button
type="button"
class="flex-1 bg-amber-600 hover:bg-amber-500 text-black font-semibold px-4 py-2 rounded"
@click="setKakaoReplacementApproval(false)"
>
교체 승인
</button>
<button
type="button"
class="flex-1 bg-zinc-700 hover:bg-zinc-600 px-4 py-2 rounded"
@click="setKakaoReplacementApproval(true)"
>
승인 해제
</button>
</div>
<div class="text-xs text-zinc-500">{{ kakaoReplacementStatus }}</div>
</div>
<div
v-if="userWorkspaceSection === 'access'"
class="bg-zinc-900 border border-zinc-800 rounded-lg p-5 space-y-4"
+24 -1
View File
@@ -11,7 +11,7 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
| 메뉴 | 경로 | 책임 |
| --------------- | ---------------------------------------------- | ------------------------------------------------------------------------- |
| 운영 개요 | `/gateway/admin` | 현재 권한으로 접근할 수 있는 관리 영역 안내 |
| 사용자 관리 | `/gateway/admin/users` | 계정 조회·생성, 권한, 특수 접근·OAuth 유예, 제재, 아이콘 복구와 탈퇴 예약 |
| 사용자 관리 | `/gateway/admin/users` | 계정 식별자·Kakao 교체, 권한, 특수 접근·제재, 아이콘 복구와 탈퇴 예약 |
| 서버 관리 | `/gateway/admin/servers` | 접근 가능한 profile 목록 |
| 서버 상태·설정 | `/gateway/admin/servers/:profileName` | 해당 profile의 공개 정보, 계정 정책, 실행 상태와 게임 운영 동작 |
| 버전 업데이트 | `/gateway/admin/servers/:profileName/version` | 현 DB를 보존하는 profile 코드·migration 배포 |
@@ -131,6 +131,29 @@ Gateway 관리자 콘솔은 `/gateway/admin`에서 시작합니다. 공개 로
브라우저의 확인 입력과 confirm은 오조작 방지 UI이고, 실제 경계는 Gateway API의
capability 검사와 orchestrator의 상태·lease 검사입니다.
## 계정 식별자와 Kakao 교체
사용자 설정에서는 로그인 ID와 표시명을 변경할 수 없습니다. `admin.users.manage`
권한을 가진 관리자는 사용자 관리의 계정 영역에서 두 값을 함께 변경할 수 있습니다.
변경된 표시명은 실행 중인 각 profile의 현재 인간 장수에 revision 기반 event로
투영됩니다. 과거 `OldGeneral`, `HallOfFame`, 중앙 archive는 당시 이름을 보존하는
불변 snapshot이므로 소급 변경하지 않습니다. 변경 후 생성되는 기록만 새 표시명을
사용합니다.
Kakao 계정 자체를 바꾸는 고객지원 작업은 다음 두 단계로 수행합니다.
1. 관리자가 기존 Kakao 연결 계정에 사유와 7일 이내 만료 시각을 입력해 교체를
승인합니다.
2. 사용자가 계정 설정에서 `새 카카오 계정으로 교체`를 누르고 새 계정으로 Kakao
OAuth를 완료합니다.
서버는 callback 시점에 승인 만료와 새 stable ID·verified email의 소유권을 다시
확인합니다. 성공하면 기존 stable ID를 영구 폐기 목록에 넣고 새 ID를 연결하며,
모든 기존 Gateway session을 무효화합니다. 폐기된 stable ID는 원 계정이 삭제된
후에도 로그인·가입·복구·다른 계정 연결에 다시 사용할 수 없습니다. 관리자 화면에서
새 stable ID를 직접 입력하거나 OAuth 증명을 대신할 수 없습니다. 승인 취소나 만료는
교체 전에만 효력이 있으며, 완료된 폐기는 되돌리지 않습니다.
## Kakao 없는 특수 계정 접근
운영자 role(`superuser`, `admin`, `admin.*`)은 별도 grant 없이 모든 game
+3
View File
@@ -24,6 +24,7 @@ export interface GatewayUserInfo {
id: string;
username: string;
displayName: string;
identityRevision?: string;
roles: string[];
picture?: string;
imageServer?: number;
@@ -102,6 +103,8 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
typeof user.id !== 'string' ||
typeof user.username !== 'string' ||
typeof user.displayName !== 'string' ||
(user.identityRevision !== undefined &&
(typeof user.identityRevision !== 'string' || !isCanonicalIsoTimestamp(user.identityRevision))) ||
!Array.isArray(user.roles) ||
(user.picture !== undefined && typeof user.picture !== 'string') ||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
+19
View File
@@ -348,6 +348,13 @@ export type TurnDaemonCommand =
iconRevision: string;
enforceCooldown?: boolean;
}
| {
type: 'adjustGeneralIdentity';
requestId?: string;
userId: string;
displayName: string;
identityRevision: string;
}
| {
type: 'joinCreateGeneral';
requestId?: string;
@@ -757,6 +764,18 @@ export type TurnDaemonCommandResult =
reason: string;
availableAt?: string;
}
| {
type: 'adjustGeneralIdentity';
ok: true;
generalId: number | null;
updated: boolean;
}
| {
type: 'adjustGeneralIdentity';
ok: false;
code: 'CONFLICT' | 'PRECONDITION_FAILED';
reason: string;
}
| {
type: 'joinCreateGeneral';
ok: true;
@@ -0,0 +1,23 @@
ALTER TABLE "app_user"
ADD COLUMN "identity_revision" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
ADD COLUMN "auth_revision" INTEGER NOT NULL DEFAULT 0,
ADD COLUMN "session_revoked_before" TIMESTAMP(3),
ADD COLUMN "kakao_replacement_approved_until" TIMESTAMP(3),
ADD COLUMN "kakao_replacement_approved_by_user_id" TEXT,
ADD COLUMN "kakao_replacement_reason" TEXT;
CREATE TABLE "retired_kakao_identity" (
"id" TEXT NOT NULL,
"oauth_id" TEXT NOT NULL,
"former_user_id" TEXT NOT NULL,
"approved_by_user_id" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"retired_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "retired_kakao_identity_pkey" PRIMARY KEY ("id")
);
CREATE UNIQUE INDEX "retired_kakao_identity_oauth_id_key"
ON "retired_kakao_identity"("oauth_id");
CREATE INDEX "retired_kakao_identity_former_user_id_retired_at_idx"
ON "retired_kakao_identity"("former_user_id", "retired_at");
+67 -49
View File
@@ -79,45 +79,63 @@ enum GatewaySourceMode {
}
model AppUser {
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @unique @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
passwordResetRequired Boolean @default(false) @map("password_reset_required")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
iconRevision DateTime? @map("icon_revision")
profileIconResetAt DateTime? @map("profile_icon_reset_at")
iconRetiredAt DateTime? @map("icon_retired_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
termsAcceptedAt DateTime? @map("terms_accepted_at")
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoTalkVerifiedUntil DateTime? @map("kakao_talk_verified_until")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[]
webPushPreferences WebPushPreference[]
webPushNotifications WebPushNotification[]
id String @id @default(uuid())
loginId String @unique @map("login_id")
displayName String @unique @map("display_name")
passwordHash String @map("password_hash")
passwordSalt String @map("password_salt")
passwordResetRequired Boolean @default(false) @map("password_reset_required")
roles Json @default(dbgenerated("'[]'::jsonb"))
sanctions Json @default(dbgenerated("'{}'::jsonb"))
oauthType OAuthType @default(NONE) @map("oauth_type")
oauthId String? @unique @map("oauth_id")
email String? @unique
oauthInfo Json @default(dbgenerated("'{}'::jsonb")) @map("oauth_info")
identityRevision DateTime @default(now()) @map("identity_revision")
authRevision Int @default(0) @map("auth_revision")
sessionRevokedBefore DateTime? @map("session_revoked_before")
kakaoReplacementApprovedUntil DateTime? @map("kakao_replacement_approved_until")
kakaoReplacementApprovedByUserId String? @map("kakao_replacement_approved_by_user_id")
kakaoReplacementReason String? @map("kakao_replacement_reason")
picture String @default("default.jpg")
imageServer Int @default(0) @map("image_server")
iconUpdatedAt DateTime? @map("icon_updated_at")
iconRevision DateTime? @map("icon_revision")
profileIconResetAt DateTime? @map("profile_icon_reset_at")
iconRetiredAt DateTime? @map("icon_retired_at")
thirdPartyUse Boolean @default(true) @map("third_party_use")
termsAcceptedAt DateTime? @map("terms_accepted_at")
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
kakaoVerifiedAt DateTime? @map("kakao_verified_at")
kakaoTalkVerifiedUntil DateTime? @map("kakao_talk_verified_until")
kakaoGraceStartedAt DateTime @default(now()) @map("kakao_grace_started_at")
kakaoGraceUntil DateTime? @map("kakao_grace_until")
deleteAfter DateTime? @map("delete_after")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
lastLoginAt DateTime? @map("last_login_at")
legacyData Json @default(dbgenerated("'{}'::jsonb")) @map("legacy_data")
icons UserIcon[]
specialAccessGrants SpecialAccountAccessGrant[]
webPushSubscriptions WebPushSubscription[]
webPushPreferences WebPushPreference[]
webPushNotifications WebPushNotification[]
@@map("app_user")
}
model RetiredKakaoIdentity {
id String @id @default(uuid())
oauthId String @unique @map("oauth_id")
formerUserId String @map("former_user_id")
approvedByUserId String @map("approved_by_user_id")
reason String
retiredAt DateTime @default(now()) @map("retired_at")
@@index([formerUserId, retiredAt])
@@map("retired_kakao_identity")
}
model SpecialAccountAccessGrant {
id String @id @default(uuid())
userId String @map("user_id")
@@ -244,18 +262,18 @@ model GatewayProfile {
}
model WebPushSubscription {
id String @id @default(uuid())
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique @db.Text
p256dh String @db.Text
auth String @db.Text
expirationTime DateTime? @map("expiration_time")
userAgent String? @map("user_agent") @db.Text
disabledAt DateTime? @map("disabled_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
id String @id @default(uuid())
userId String @map("user_id")
user AppUser @relation(fields: [userId], references: [id], onDelete: Cascade)
endpoint String @unique @db.Text
p256dh String @db.Text
auth String @db.Text
expirationTime DateTime? @map("expiration_time")
userAgent String? @map("user_agent") @db.Text
disabledAt DateTime? @map("disabled_at")
lastSeenAt DateTime @default(now()) @map("last_seen_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deliveries WebPushDelivery[]
@@index([userId, disabledAt, updatedAt])
@@ -328,11 +346,11 @@ model WebPushDelivery {
}
model WebPushProfileCursor {
profileName String @id @map("profile_name")
profileName String @id @map("profile_name")
status String
preopenAt DateTime? @map("preopen_at")
openAt DateTime? @map("open_at")
updatedAt DateTime @updatedAt @map("updated_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("web_push_profile_cursor")
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"formatVersion": 1,
"controllerProtocol": 2,
"gatewaySchemaHead": "20260824090000_allow_interim_profile_deploy",
"gatewaySchemaHead": "20260824120000_add_account_identity_management",
"gameSchemaHead": "20260824080000_vote_utc_wall_timestamps",
"components": ["gateway-api", "gateway-frontend", "release-controller", "game-api", "game-engine", "game-frontend"]
}