merge: 관리자 계정 식별자와 카카오 영구 교체를 반영한다
This commit is contained in:
@@ -3,6 +3,8 @@ export interface GatewayUserFlushEvent {
|
||||
flushedAt: string;
|
||||
reason?: string;
|
||||
iconRevision?: string;
|
||||
displayName?: string;
|
||||
identityRevision?: string;
|
||||
}
|
||||
|
||||
export interface FlushStore {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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 }) =>
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)) ||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+23
@@ -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");
|
||||
@@ -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,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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user