fix sanctions and admin role escalation
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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>): 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 = {
|
||||
|
||||
@@ -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<string>,
|
||||
nextRoles: ReadonlySet<string>
|
||||
): 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');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { UserSanctions, UserServerRestriction } from './gameToken.js';
|
||||
|
||||
export type SanctionFeature = 'login' | 'game' | 'messages';
|
||||
|
||||
const FEATURE_ALIASES: Record<SanctionFeature, ReadonlySet<string>> = {
|
||||
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);
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user