From ab6ed3553ae2892e697012992db955710847da6e Mon Sep 17 00:00:00 2001 From: hided62 Date: Sun, 26 Jul 2026 18:11:01 +0000 Subject: [PATCH] fix sanctions and admin role escalation --- app/game-api/src/router/auth/index.ts | 7 + app/game-api/src/router/messages/index.ts | 30 +--- app/game-api/src/trpc.ts | 8 + app/game-api/test/messagesRouter.test.ts | 25 +++ app/game-api/test/router.test.ts | 88 ++++++++-- app/gateway-api/src/adminRouter.ts | 57 ++++++- app/gateway-api/src/router.ts | 25 +++ app/gateway-api/test/adminOperations.test.ts | 160 ++++++++++++++++++- app/gateway-api/test/authFlow.test.ts | 123 +++++++++++++- packages/common/package.json | 4 + packages/common/src/auth/sanctions.ts | 68 ++++++++ packages/common/test/sanctions.test.ts | 46 ++++++ packages/common/tsdown.config.ts | 1 + 13 files changed, 591 insertions(+), 51 deletions(-) create mode 100644 packages/common/src/auth/sanctions.ts create mode 100644 packages/common/test/sanctions.test.ts diff --git a/app/game-api/src/router/auth/index.ts b/app/game-api/src/router/auth/index.ts index aa7b4ea..653c393 100644 --- a/app/game-api/src/router/auth/index.ts +++ b/app/game-api/src/router/auth/index.ts @@ -1,5 +1,6 @@ import { TRPCError } from '@trpc/server'; import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; +import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { isAfter, isValid, parseISO } from 'date-fns'; import { z } from 'zod'; @@ -58,6 +59,12 @@ export const authRouter = router({ message: 'Invalid gateway token.', }); } + if (isGameAccessBlocked(payload.sanctions, [ctx.profile.name, ctx.profile.id])) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Game access is restricted for this account.', + }); + } const flushedAt = ctx.flushStore.getFlushedAt(payload.user.id); if (flushedAt && new Date(payload.issuedAt) <= flushedAt) { throw new TRPCError({ diff --git a/app/game-api/src/router/messages/index.ts b/app/game-api/src/router/messages/index.ts index b6a49aa..5308bf2 100644 --- a/app/game-api/src/router/messages/index.ts +++ b/app/game-api/src/router/messages/index.ts @@ -2,6 +2,7 @@ import { TRPCError } from '@trpc/server'; import { z } from 'zod'; import { asRecord } from '@sammo-ts/common'; import type { UserSanctions } from '@sammo-ts/common/auth/gameToken'; +import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import { authedProcedure, router } from '../../trpc.js'; import { @@ -47,35 +48,8 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M }); }; -const isFutureDate = (value: string | undefined, now = Date.now()): boolean => { - if (!value) { - return false; - } - const parsed = Date.parse(value); - return Number.isFinite(parsed) && parsed > now; -}; - const isMessageFeatureBlocked = (sanctions: UserSanctions, profileNames: string[]): boolean => { - if ( - isFutureDate(sanctions.mutedUntil) || - isFutureDate(sanctions.suspendedUntil) || - isFutureDate(sanctions.bannedUntil) - ) { - return true; - } - for (const profileName of profileNames) { - const restriction = sanctions.serverRestrictions?.[profileName]; - if (!restriction) { - continue; - } - if (restriction.until && !isFutureDate(restriction.until)) { - continue; - } - if (restriction.blockedFeatures?.includes('messages')) { - return true; - } - } - return false; + return isMessageAccessBlocked(sanctions, profileNames); }; const readPenaltyNumber = (penalty: unknown, key: string, fallback: number): number => { diff --git a/app/game-api/src/trpc.ts b/app/game-api/src/trpc.ts index 880fa42..0c225b8 100644 --- a/app/game-api/src/trpc.ts +++ b/app/game-api/src/trpc.ts @@ -1,5 +1,6 @@ import { randomUUID } from 'node:crypto'; import { initTRPC, TRPCError } from '@trpc/server'; +import { isGameAccessBlocked } from '@sammo-ts/common/auth/sanctions'; import type { GameApiContext } from './context.js'; import { IdempotentTurnDaemonTransport } from './daemon/idempotentTransport.js'; @@ -14,6 +15,13 @@ const requireAuthMiddleware = t.middleware(({ ctx, next }) => { message: 'Unauthorized', }); } + const profileNames = ctx.profile ? [ctx.profile.name, ctx.profile.id] : []; + if (isGameAccessBlocked(ctx.auth.sanctions, profileNames)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Game access is restricted for this account.', + }); + } return next({ ctx: { ...ctx, diff --git a/app/game-api/test/messagesRouter.test.ts b/app/game-api/test/messagesRouter.test.ts index b96645c..af4e208 100644 --- a/app/game-api/test/messagesRouter.test.ts +++ b/app/game-api/test/messagesRouter.test.ts @@ -358,6 +358,31 @@ describe('messages router missing-flow compatibility', () => { }); }); + it.each(['message', 'messages'])('blocks sends for the profile feature alias %s', async (feature) => { + const restrictedAuth = { + ...auth, + sanctions: { + serverRestrictions: { + 'che:default': { + blockedFeatures: [feature], + }, + }, + }, + }; + const { caller } = buildContext({}, { auth: restrictedAuth }); + + await expect( + caller.messages.send({ + generalId: general.id, + mailbox: 9999, + text: 'profile restriction', + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: '메시지 전송이 제한된 계정입니다.', + }); + }); + it('rejects every remaining general-scoped message mutation for another user general', async () => { const foreignGeneral = { ...general, userId: 'user-8' } as GeneralRow; const { caller } = buildContext({ diff --git a/app/game-api/test/router.test.ts b/app/game-api/test/router.test.ts index 17a324b..dfb010c 100644 --- a/app/game-api/test/router.test.ts +++ b/app/game-api/test/router.test.ts @@ -23,6 +23,21 @@ const profile: GameProfile = { name: 'che:default', }; +const buildAuth = (sanctions: GameSessionTokenPayload['sanctions'] = {}): GameSessionTokenPayload => ({ + version: 1, + profile: profile.name, + issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), + expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), + sessionId: 'session-1', + user: { + id: 'user-1', + username: 'tester', + displayName: 'Tester', + roles: ['admin'], + }, + sanctions, +}); + const buildGeneralRow = (overrides?: Partial): GeneralRow => { const base: GeneralRow = { id: 1, @@ -137,20 +152,7 @@ const buildContext = (options?: { }, profile.name ); - const defaultAuth: GameSessionTokenPayload = { - version: 1, - profile: profile.name, - issuedAt: new Date('2026-01-01T00:00:00Z').toISOString(), - expiresAt: new Date('2026-01-02T00:00:00Z').toISOString(), - sessionId: 'session-1', - user: { - id: 'user-1', - username: 'tester', - displayName: 'Tester', - roles: ['admin'], - }, - sanctions: {}, - }; + const defaultAuth = buildAuth(); const auth = options && 'auth' in options ? (options.auth ?? null) : defaultAuth; return { db: db as unknown as DatabaseClient, @@ -169,6 +171,64 @@ const buildContext = (options?: { }; describe('appRouter', () => { + const blockedGameAccessCases: Array<{ + label: string; + sanctions: GameSessionTokenPayload['sanctions']; + }> = [ + { + label: 'global suspension', + sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' }, + }, + { + label: 'instance gameplay restriction', + sanctions: { + serverRestrictions: { + 'che:default': { blockedFeatures: ['game'] }, + }, + }, + }, + { + label: 'base-profile wildcard restriction', + sanctions: { + serverRestrictions: { + che: { blockedFeatures: ['*'], until: '2099-01-01T00:00:00.000Z' }, + }, + }, + }, + ]; + + it.each(blockedGameAccessCases)('blocks authenticated game API access for $label', async ({ sanctions }) => { + const caller = appRouter.createCaller( + buildContext({ + auth: buildAuth(sanctions), + general: buildGeneralRow({ id: 11 }), + }) + ); + + await expect(caller.turns.reserved.getGeneral({ generalId: 11 })).rejects.toMatchObject({ + code: 'FORBIDDEN', + }); + }); + + it('does not apply an expired or message-only restriction to other game APIs', async () => { + const caller = appRouter.createCaller( + buildContext({ + auth: buildAuth({ + mutedUntil: '2099-01-01T00:00:00.000Z', + serverRestrictions: { + 'che:default': { + blockedFeatures: ['messages'], + until: '2000-01-01T00:00:00.000Z', + }, + }, + }), + general: buildGeneralRow({ id: 11 }), + }) + ); + + await expect(caller.turns.reserved.getGeneral({ generalId: 11 })).resolves.toBeDefined(); + }); + it('rejects general creation before any game-state read when the signed identity gate denies it', async () => { const worldStateReads = { count: 0 }; const auth: GameSessionTokenPayload = { diff --git a/app/gateway-api/src/adminRouter.ts b/app/gateway-api/src/adminRouter.ts index 936174c..2cd72c4 100644 --- a/app/gateway-api/src/adminRouter.ts +++ b/app/gateway-api/src/adminRouter.ts @@ -130,6 +130,56 @@ const hasScopedPermission = (adminAuth: AdminAuthContext, permission: string, pr return adminAuth.roles.some((role: string) => roleMatchesScope(role, permission, profileName)); }; +const splitRoleScope = (role: string): { permission: string; scope?: string } => { + const separator = role.indexOf(':'); + if (separator < 0) { + return { permission: role }; + } + return { + permission: role.slice(0, separator), + scope: role.slice(separator + 1), + }; +}; + +const isRootAdminRole = (role: string): boolean => + role === ROLE_SUPERUSER || role === 'admin' || role === ADMIN_ROLE_SUPERUSER; + +const canManageRole = (adminAuth: AdminAuthContext, role: string): boolean => { + if (adminAuth.isSuperuser) { + return true; + } + if (isRootAdminRole(role)) { + return false; + } + const target = splitRoleScope(role); + return adminAuth.roles.some((callerRole) => { + const caller = splitRoleScope(callerRole); + if (caller.permission !== target.permission) { + return false; + } + return caller.scope === undefined || caller.scope === '*' || caller.scope === target.scope; + }); +}; + +const assertRoleChangesAllowed = ( + adminAuth: AdminAuthContext, + currentRoles: ReadonlySet, + nextRoles: ReadonlySet +): void => { + const changedRoles = new Set([...currentRoles, ...nextRoles]); + for (const role of changedRoles) { + if (currentRoles.has(role) === nextRoles.has(role)) { + continue; + } + if (!canManageRole(adminAuth, role)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: `Role change exceeds caller scope: ${role}`, + }); + } + } +}; + const assertPermission = (adminAuth: AdminAuthContext, permission: string, profileName?: string): void => { if (hasScopedPermission(adminAuth, permission, profileName)) { return; @@ -465,7 +515,7 @@ export const adminRouter = router({ .input( z.object({ userId: z.string().min(1), - roles: z.array(z.string().min(1)).min(1), + roles: z.array(z.string().trim().min(1).max(128)).min(1), mode: zUserRoleMode.optional(), }) ) @@ -478,7 +528,8 @@ export const adminRouter = router({ }); } const mode = input.mode ?? 'set'; - const roles = new Set(user.roles); + const currentRoles = new Set(user.roles); + const roles = new Set(currentRoles); if (mode === 'set') { roles.clear(); for (const role of input.roles) { @@ -493,6 +544,8 @@ export const adminRouter = router({ roles.delete(role); } } + const adminAuth = requireAdminAuth(ctx); + assertRoleChangesAllowed(adminAuth, currentRoles, roles); const nextRoles = Array.from(roles); await ctx.users.updateRoles(input.userId, nextRoles); await ctx.flushPublisher.publishUserFlush(input.userId, 'admin-roles-updated'); diff --git a/app/gateway-api/src/router.ts b/app/gateway-api/src/router.ts index 7b7149b..feb1024 100644 --- a/app/gateway-api/src/router.ts +++ b/app/gateway-api/src/router.ts @@ -5,6 +5,7 @@ import { addHours, addSeconds, isAfter, isValid, parseISO } from 'date-fns'; import { z } from 'zod'; import { decryptGameSessionToken, encryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; +import { isGameAccessBlocked, isLoginBanned } from '@sammo-ts/common/auth/sanctions'; import { procedure, router } from './trpc.js'; import { toPublicUser } from './auth/userRepository.js'; @@ -250,6 +251,12 @@ export const appRouter = router({ message: '연결할 로컬 계정을 찾지 못했습니다.', }); } + if (isLoginBanned(localUser.sanctions)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account login is blocked.', + }); + } if (existing && existing.id !== localUser.id) { throw new TRPCError({ code: 'CONFLICT', @@ -323,6 +330,12 @@ export const appRouter = router({ } if (existing) { + if (isLoginBanned(existing.sanctions)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account login is blocked.', + }); + } await ctx.users.updateOAuthInfo(existing.id, oauthInfo); const session = await ctx.sessions.createSession(existing); return { @@ -550,6 +563,12 @@ export const appRouter = router({ message: 'Invalid username or password.', }); } + if (isLoginBanned(user.sanctions)) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Account login is blocked.', + }); + } const session = await ctx.sessions.createSession(user); return { user: toPublicUser(user), @@ -615,6 +634,12 @@ export const appRouter = router({ } const profileRecord = await ctx.profiles.getProfile(input.profile); const profile = profileRecord?.profile ?? input.profile.split(':', 1)[0] ?? input.profile; + if (isGameAccessBlocked(user.sanctions, [input.profile, profile])) { + throw new TRPCError({ + code: 'FORBIDDEN', + message: 'Game access is restricted for this account.', + }); + } const localAccountPolicy = resolveLocalAccountProfilePolicy({ profile, profileMeta: profileRecord?.meta, diff --git a/app/gateway-api/test/adminOperations.test.ts b/app/gateway-api/test/adminOperations.test.ts index ad56602..8cf4566 100644 --- a/app/gateway-api/test/adminOperations.test.ts +++ b/app/gateway-api/test/adminOperations.test.ts @@ -10,20 +10,25 @@ import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService. import { appRouter } from '../src/router.js'; import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; -const buildCaller = async (createOperation: GatewayProfileRepository['createOperation']) => { +const buildCaller = async ( + createOperation: GatewayProfileRepository['createOperation'], + options: { adminRoles?: string[]; firstUserIsAdmin?: boolean } = {} +) => { const users = createInMemoryUserRepository(); const admin = await users.createUser({ username: 'admin', password: 'secretpass', displayName: 'Admin', }); - await users.updateRoles(admin.id, ['superuser']); + const adminRoles = options.adminRoles ?? ['superuser']; + await users.updateRoles(admin.id, adminRoles); const sessions = new InMemoryGatewaySessionService({ sessionTtlSeconds: 600, gameSessionTtlSeconds: 600, }); - const session = await sessions.createSession({ ...admin, roles: ['superuser'] }); + const session = await sessions.createSession({ ...admin, roles: adminRoles }); const createdInputs: GatewayOperationCreateInput[] = []; + const flushes: Array<{ userId: string; reason?: string }> = []; const profile = { profileName: 'che:2', profile: 'che', @@ -68,7 +73,11 @@ const buildCaller = async (createOperation: GatewayProfileRepository['createOper createGatewayApiContext({ users, sessions, - flushPublisher: { publishUserFlush: async () => {} }, + flushPublisher: { + publishUserFlush: async (userId, reason) => { + flushes.push({ userId, reason }); + }, + }, gameTokenSecret: 'test-secret', gameSessionTtlSeconds: 600, kakaoClient: {} as never, @@ -93,12 +102,12 @@ const buildCaller = async (createOperation: GatewayProfileRepository['createOper requestHeaders: { 'x-session-token': session.sessionToken }, prisma: { appUser: { - findFirst: async () => ({ id: admin.id }), + findFirst: async () => ({ id: options.firstUserIsAdmin === false ? 'bootstrap-user' : admin.id }), }, } as unknown as GatewayPrismaClient, }) ); - return { caller, createdInputs }; + return { caller, createdInputs, users, admin, flushes }; }; describe('admin operation API', () => { @@ -142,3 +151,142 @@ describe('admin operation API', () => { ).rejects.toMatchObject({ code: 'CONFLICT' }); }); }); + +describe('admin role non-escalation', () => { + const unusedCreateOperation: GatewayProfileRepository['createOperation'] = async () => { + throw new Error('not used'); + }; + + it('allows a scoped administrator to grant only the same scoped role', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage', 'admin.survey.open:che:default'], + firstUserIsAdmin: false, + }); + const target = await harness.users.createUser({ + username: 'target-user', + password: 'secretpass', + displayName: 'Target', + }); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: ['admin.survey.open:che:default'], + mode: 'grant', + }) + ).resolves.toMatchObject({ + roles: ['user', 'admin.survey.open:che:default'], + }); + }); + + it.each(['admin.survey.open:*', 'admin.survey.open:hwe:default', 'superuser', 'admin'])( + 'rejects granting a broader or root role: %s', + async (role) => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage', 'admin.survey.open:che:default'], + firstUserIsAdmin: false, + }); + const target = await harness.users.createUser({ + username: `target-${role.replaceAll(/[^a-z]/g, '-')}`, + password: 'secretpass', + displayName: 'Target', + }); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: [role], + mode: 'grant', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + } + ); + + it('rejects set mode when it would remove a role outside the caller scope', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage'], + firstUserIsAdmin: false, + }); + const target = await harness.users.createUser({ + username: 'privileged-target', + password: 'secretpass', + displayName: 'Privileged Target', + }); + await harness.users.updateRoles(target.id, ['user', 'admin.survey.open:*']); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: ['user'], + mode: 'set', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + + it('rejects self-escalation to a broader wildcard scope', async () => { + const harness = await buildCaller(unusedCreateOperation, { + adminRoles: ['user', 'admin.users.manage', 'admin.survey.open:che:default'], + firstUserIsAdmin: false, + }); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: harness.admin.id, + roles: ['admin.survey.open:*'], + mode: 'grant', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + expect((await harness.users.findById(harness.admin.id))?.roles).toEqual([ + 'user', + 'admin.users.manage', + 'admin.survey.open:che:default', + ]); + }); + + it('allows a superuser to change root roles', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'admin-target', + password: 'secretpass', + displayName: 'Admin Target', + }); + + await expect( + harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: ['superuser'], + mode: 'grant', + }) + ).resolves.toMatchObject({ roles: ['user', 'superuser'] }); + }); + + it('flushes active sessions after role and sanction changes', async () => { + const harness = await buildCaller(unusedCreateOperation); + const target = await harness.users.createUser({ + username: 'flush-target', + password: 'secretpass', + displayName: 'Flush Target', + }); + + await harness.caller.admin.users.updateRoles({ + userId: target.id, + roles: ['admin.survey.open:che:default'], + mode: 'grant', + }); + await harness.caller.admin.users.updateSanctions({ + userId: target.id, + patch: { suspendedUntil: '2099-01-01T00:00:00.000Z' }, + }); + await harness.caller.admin.users.setServerRestriction({ + userId: target.id, + profile: 'che:default', + restriction: { blockedFeatures: ['login'] }, + }); + + expect(harness.flushes).toEqual([ + { userId: target.id, reason: 'admin-roles-updated' }, + { userId: target.id, reason: 'admin-sanctions-updated' }, + { userId: target.id, reason: 'admin-server-restriction' }, + ]); + }); +}); diff --git a/app/gateway-api/test/authFlow.test.ts b/app/gateway-api/test/authFlow.test.ts index c93b1f9..387fe3a 100644 --- a/app/gateway-api/test/authFlow.test.ts +++ b/app/gateway-api/test/authFlow.test.ts @@ -13,7 +13,7 @@ import { createGatewayApiContext } from '../src/context.js'; import { InMemoryProfileStatusService } from '../src/lobby/profileStatusService.js'; import { appRouter } from '../src/router.js'; import type { GatewayPrismaClient } from '@sammo-ts/infra'; -import { decryptGameSessionToken } from '@sammo-ts/common/auth/gameToken'; +import { decryptGameSessionToken, type UserSanctions } from '@sammo-ts/common/auth/gameToken'; import { createPasswordEnvelopeService } from '../src/auth/passwordEnvelope.js'; const buildCaller = (options: { userIconDir?: string; localAccountGraceDays?: number } = {}) => { @@ -223,6 +223,90 @@ describe('gateway auth flow', () => { expect(login.user.username).toBe('local-user'); }); + it('blocks password login while a ban is active and allows it after expiry', async () => { + const { caller, users, sealPassword } = buildCaller(); + await caller.auth.registerLocal({ + username: 'banned-user', + credential: sealPassword('banned-password'), + displayName: '차단유저', + termsAgreed: true, + privacyAgreed: true, + thirdPartyUse: false, + }); + const user = await users.findByUsername('banned-user'); + expect(user).not.toBeNull(); + await users.updateSanctions(user!.id, { + bannedUntil: '2099-01-01T00:00:00.000Z', + }); + + await expect( + caller.auth.login({ + username: 'banned-user', + credential: sealPassword('banned-password'), + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + + await users.updateSanctions(user!.id, { + bannedUntil: '2000-01-01T00:00:00.000Z', + }); + await expect( + caller.auth.login({ + username: 'banned-user', + credential: sealPassword('banned-password'), + }) + ).resolves.toMatchObject({ user: { username: 'banned-user' } }); + }); + + const gameSessionRestrictionCases: Array<{ label: string; sanctions: UserSanctions }> = [ + { + label: 'global suspension', + sanctions: { suspendedUntil: '2099-01-01T00:00:00.000Z' }, + }, + { + label: 'profile login restriction', + sanctions: { + serverRestrictions: { + 'che:default': { + blockedFeatures: ['login'], + }, + }, + }, + }, + { + label: 'base profile gameplay restriction', + sanctions: { + serverRestrictions: { + che: { + blockedFeatures: ['gameplay'], + until: '2099-01-01T00:00:00.000Z', + }, + }, + }, + }, + ]; + + it.each(gameSessionRestrictionCases)('blocks game-session issuance for $label', async ({ sanctions }) => { + const { caller, users, sealPassword } = buildCaller(); + const register = await caller.auth.registerLocal({ + username: `restricted-${Object.keys(sanctions)[0]}`, + credential: sealPassword('restricted-password'), + displayName: '제한유저', + termsAgreed: true, + privacyAgreed: true, + thirdPartyUse: false, + }); + const user = await users.findByUsername(register.user.username); + expect(user).not.toBeNull(); + await users.updateSanctions(user!.id, sanctions); + + await expect( + caller.auth.issueGameSession({ + sessionToken: register.sessionToken, + profile: 'che:default', + }) + ).rejects.toMatchObject({ code: 'FORBIDDEN' }); + }); + it('blocks pre-verification general creation on che but grants the hwe grace period', async () => { const { caller, sealPassword, setSessionHeader } = buildCaller(); const register = await caller.auth.registerLocal({ @@ -324,6 +408,43 @@ describe('gateway auth flow', () => { expect(stored?.kakaoVerifiedAt).toBeTruthy(); }); + it('blocks Kakao login while a ban is active', async () => { + const { caller, users, sealPassword, setSessionHeader } = buildCaller(); + const register = await caller.auth.registerLocal({ + username: 'kakao-banned-user', + credential: sealPassword('kakao-banned-password'), + displayName: '카카오제재유저', + termsAgreed: true, + privacyAgreed: true, + thirdPartyUse: false, + }); + setSessionHeader(register.sessionToken); + const verifyStart = await caller.auth.kakaoStart({ mode: 'verify' }); + await caller.auth.kakaoExchange({ + code: 'oauth-code', + state: verifyStart.state, + }); + + const stored = await users.findByUsername('kakao-banned-user'); + expect(stored).not.toBeNull(); + if (stored) { + await users.updateSanctions(stored.id, { + bannedUntil: new Date(Date.now() + 60_000).toISOString(), + }); + } + + const loginStart = await caller.auth.kakaoStart({ mode: 'login' }); + await expect( + caller.auth.kakaoExchange({ + code: 'oauth-code', + state: loginStart.state, + }) + ).rejects.toMatchObject({ + code: 'FORBIDDEN', + message: 'Account login is blocked.', + }); + }); + it('carries the bootstrap superuser role into game sessions', async () => { const previousToken = process.env.GATEWAY_BOOTSTRAP_TOKEN; process.env.GATEWAY_BOOTSTRAP_TOKEN = 'bootstrap-test-token'; diff --git a/packages/common/package.json b/packages/common/package.json index 6b80d52..c3c1599 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -13,6 +13,10 @@ "./auth/gameToken": { "types": "./dist/auth/gameToken.d.ts", "default": "./dist/auth/gameToken.js" + }, + "./auth/sanctions": { + "types": "./dist/auth/sanctions.d.ts", + "default": "./dist/auth/sanctions.js" } }, "scripts": { diff --git a/packages/common/src/auth/sanctions.ts b/packages/common/src/auth/sanctions.ts new file mode 100644 index 0000000..5bfeeaa --- /dev/null +++ b/packages/common/src/auth/sanctions.ts @@ -0,0 +1,68 @@ +import type { UserSanctions, UserServerRestriction } from './gameToken.js'; + +export type SanctionFeature = 'login' | 'game' | 'messages'; + +const FEATURE_ALIASES: Record> = { + login: new Set(['login']), + game: new Set(['*', 'game', 'gameplay']), + messages: new Set(['*', 'message', 'messages']), +}; + +export const isFutureSanctionDate = (value: string | undefined, now = new Date()): boolean => { + if (!value) { + return false; + } + const parsed = Date.parse(value); + return Number.isFinite(parsed) && parsed > now.getTime(); +}; + +export const isActiveServerRestriction = ( + restriction: UserServerRestriction | undefined, + now = new Date() +): restriction is UserServerRestriction => { + if (!restriction) { + return false; + } + return restriction.until === undefined || isFutureSanctionDate(restriction.until, now); +}; + +export const isProfileFeatureBlocked = ( + sanctions: UserSanctions, + profileNames: readonly string[], + feature: SanctionFeature, + now = new Date() +): boolean => { + const aliases = FEATURE_ALIASES[feature]; + for (const profileName of new Set(profileNames)) { + const restriction = sanctions.serverRestrictions?.[profileName]; + if (!isActiveServerRestriction(restriction, now)) { + continue; + } + if (restriction.blockedFeatures?.some((blockedFeature) => aliases.has(blockedFeature.trim().toLowerCase()))) { + return true; + } + } + return false; +}; + +export const isLoginBanned = (sanctions: UserSanctions, now = new Date()): boolean => + isFutureSanctionDate(sanctions.bannedUntil, now); + +export const isGameAccessBlocked = ( + sanctions: UserSanctions, + profileNames: readonly string[], + now = new Date() +): boolean => + isLoginBanned(sanctions, now) || + isFutureSanctionDate(sanctions.suspendedUntil, now) || + isProfileFeatureBlocked(sanctions, profileNames, 'login', now) || + isProfileFeatureBlocked(sanctions, profileNames, 'game', now); + +export const isMessageAccessBlocked = ( + sanctions: UserSanctions, + profileNames: readonly string[], + now = new Date() +): boolean => + isGameAccessBlocked(sanctions, profileNames, now) || + isFutureSanctionDate(sanctions.mutedUntil, now) || + isProfileFeatureBlocked(sanctions, profileNames, 'messages', now); diff --git a/packages/common/test/sanctions.test.ts b/packages/common/test/sanctions.test.ts new file mode 100644 index 0000000..fde1bad --- /dev/null +++ b/packages/common/test/sanctions.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import { + isActiveServerRestriction, + isGameAccessBlocked, + isLoginBanned, + isMessageAccessBlocked, + isProfileFeatureBlocked, +} from '../src/auth/sanctions.js'; + +const NOW = new Date('2026-07-26T00:00:00.000Z'); + +describe('sanctions', () => { + it('treats missing restriction expiry as indefinite and ignores expired restrictions', () => { + expect(isActiveServerRestriction({ blockedFeatures: ['game'] }, NOW)).toBe(true); + expect( + isActiveServerRestriction( + { + blockedFeatures: ['game'], + until: '2026-07-25T23:59:59.999Z', + }, + NOW + ) + ).toBe(false); + }); + + it('matches profile instance and base-profile feature aliases', () => { + const sanctions = { + serverRestrictions: { + 'che:default': { blockedFeatures: ['message'] }, + che: { blockedFeatures: ['gameplay'] }, + }, + }; + + expect(isProfileFeatureBlocked(sanctions, ['che:default', 'che'], 'messages', NOW)).toBe(true); + expect(isProfileFeatureBlocked(sanctions, ['che:default', 'che'], 'game', NOW)).toBe(true); + expect(isProfileFeatureBlocked(sanctions, ['hwe:default', 'hwe'], 'game', NOW)).toBe(false); + }); + + it('separates login bans, gameplay suspension, and message mute', () => { + expect(isLoginBanned({ bannedUntil: '2099-01-01T00:00:00.000Z' }, NOW)).toBe(true); + expect(isGameAccessBlocked({ suspendedUntil: '2099-01-01T00:00:00.000Z' }, ['che'], NOW)).toBe(true); + expect(isGameAccessBlocked({ mutedUntil: '2099-01-01T00:00:00.000Z' }, ['che'], NOW)).toBe(false); + expect(isMessageAccessBlocked({ mutedUntil: '2099-01-01T00:00:00.000Z' }, ['che'], NOW)).toBe(true); + }); +}); diff --git a/packages/common/tsdown.config.ts b/packages/common/tsdown.config.ts index 4cadf17..fbe2c41 100644 --- a/packages/common/tsdown.config.ts +++ b/packages/common/tsdown.config.ts @@ -4,6 +4,7 @@ export default defineConfig({ entry: { index: 'src/index.ts', 'auth/gameToken': 'src/auth/gameToken.ts', + 'auth/sanctions': 'src/auth/sanctions.ts', }, format: 'es', outDir: 'dist',