feat: 관리자 계정 식별자와 카카오 영구 교체 지원
관리자 승인과 사용자 OAuth 증명을 분리하고 기존 Kakao stable ID를 영구 폐기한다. 로그인 ID와 닉네임 변경은 현재 장수에 revision 기반으로 투영하며 과거 기록은 당시 이름으로 보존한다.
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