ban hammer

This commit is contained in:
Devin Zuczek
2026-08-10 10:29:24 -04:00
parent 7fbaad1fd8
commit 6bbdf989b9
8 changed files with 517 additions and 27 deletions
+44 -1
View File
@@ -24,6 +24,9 @@ import {
import { intVar, logger, withCleanSpec, withDefaultCors, withNotFound, withOnError } from '@repo/hono-helpers'
import { generateToken, TOKEN_TTL_SECONDS, validateAndGetAccountId } from '@repo/jwt'
// The account-wide ban lives on a `report` row, whose table the api worker owns; its db
// module is plain D1 queries with no runtime deps, so it imports cleanly here.
import { getActiveBan } from '../../api/src/reports-db'
import { verifyMetaNonce } from './meta-nonce'
import {
CachedLogin,
@@ -58,6 +61,14 @@ import type { PlatformLink } from './platform-db'
const TOKEN_SCOPE =
'offline_access profile rn rn.accounts rn.accounts.gc rn.api rn.chat rn.clubs rn.commerce rn.match.read rn.match.write rn.notify rn.rooms rn.storage'
/**
* The `error_description` a banned account's grant is refused with. A fixed sentence,
* never interpolated with the expiry, because `www`'s shared auth-messages table keys on
* this exact string to put a real sentence in front of a player — anything varying would
* fall through to the generic "you could not be signed in". Keep the two in sync.
*/
const BANNED_DESCRIPTION = 'this account is banned'
/**
* The platform id a SIDELOADED Oculus APK reports. It is not an identity: a sideloaded
* build has no Meta SDK to ask, so it has nothing real to report — and every sideloaded
@@ -546,6 +557,12 @@ const app = new Hono<App>()
'',
'**Roles.** The token embeds a `role` claim from the account, so developer/moderator',
'powers refresh on every login and every refresh grant.',
'',
'**Bans.** Once the grant has resolved an account, a BANNED account is refused a',
'token at all (`invalid_grant`) — every grant, including a refresh. A ban is a',
'`report` row with `banned` set (the `api` worker owns that table); it lifts on its',
'own when `ban_expires` passes, and never if that is null. The ban belongs to the',
'account, so it does not stop the player creating a new one — only the signup caps do.',
].join('\n'),
requestBody: form(
TokenRequest,
@@ -557,7 +574,8 @@ const app = new Hono<App>()
OAuthError,
[
'Unusable grant: bad credentials, an unverifiable platform or platform_auth, an',
'invalid/expired refresh token, a missing account identifier, or a signup cap reached',
'invalid/expired refresh token, a missing account identifier, a signup cap reached,',
'or a banned account',
].join(' ')
),
500: json(
@@ -869,6 +887,31 @@ const app = new Hono<App>()
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
// A banned account gets no token — and with no token every other worker is shut
// to it, so this is the outer wall of a ban; matchmaking's refusal is the inner
// one, which still has to exist because a token issued before the ban stays valid
// until it expires.
//
// Checked once here, after the grant has resolved an account, so it covers every
// grant: password, cached_login and a refresh_token redeemed by a client that has
// been running since before the ban. Deliberately AFTER the credential checks —
// a wrong password is still "invalid account_id or password", so this can't be
// used to probe whether an account exists or is banned without knowing it.
//
// It is per-account, and the ban is the account's, not the person's: nothing here
// stops a banned player creating a new account and playing on. Refusing that is a
// signup-cap/platform-identity problem, not this check's.
const ban = await getActiveBan(c.env.DB, Number(accountId))
if (ban) {
logger.info('token refused: account banned', {
accountId,
grantType,
reportId: ban.id,
banExpires: ban.ban_expires,
})
return c.json({ error: 'invalid_grant', error_description: BANNED_DESCRIPTION }, 400)
}
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
// binding) would still yield a well-formed token — but one signed with an empty
// key, which every worker validates against, so anyone could forge it. Refuse to
+120
View File
@@ -15,6 +15,11 @@ import {
} from '@repo/domain'
import { TOKEN_TTL_SECONDS } from '@repo/jwt'
import {
banFromReport,
createReport,
SCHEMA_DDL as REPORTS_SCHEMA_DDL,
} from '../../../../api/src/reports-db'
import {
getLinksForAccount,
linkPlatformIdentity,
@@ -81,8 +86,27 @@ beforeAll(async () => {
IsDorm: false,
SubRooms: [{ SubRoomId: 23, UnitySceneId: ORIENTATION_SCENE, MaxPlayers: 1 }],
})
// Report table (owned by the api worker) — a banned account is refused a token, and
// a ban is a report row with `banned` set.
for (const stmt of REPORTS_SCHEMA_DDL) await env.DB.prepare(stmt).run()
})
/**
* Ban an account the way a moderator would: file a report against it and convert that
* report into a ban. `banExpires` null is a permanent ban.
*/
async function banAccount(accountId: number, banExpires: string | null = null): Promise<void> {
const row = await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: accountId })
await banFromReport(env.DB, row.id, { banExpires })
}
/** Seed an account with LOGIN_PASSWORD set, so it can be logged into. */
async function seedAccount(accountId: number, username: string): Promise<void> {
await env.DB.prepare('INSERT OR IGNORE INTO account (data) VALUES (?1)')
.bind(JSON.stringify({ accountId, username, passwordHash: await hashPassword(LOGIN_PASSWORD) }))
.run()
}
/** Decode a JWT payload (no verification) for asserting claims. */
function decodePayload(token: string): Record<string, unknown> {
const part = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
@@ -1147,3 +1171,99 @@ describe('CORS', () => {
)
})
})
// A banned account is refused a token at all — the outer wall of a ban, since with no
// token every other worker is shut to it. The ban is a `report` row with `banned` set
// (the api worker owns that table); matchmaking enforces the same ban on tokens issued
// before it was handed down.
describe('banned accounts', () => {
test('POST /connect/token refuses a password grant from a banned account', async () => {
await seedAccount(6101, 'BannedPlayer')
await banAccount(6101)
const res = await postToken(`account_id=6101&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error).toBe('invalid_grant')
// The exact sentence www's shared auth-messages table keys on to put a real
// message in front of the player — changing it silently downgrades that to the
// generic "you could not be signed in".
expect(res.json.error_description).toBe('this account is banned')
})
test('POST /connect/token refuses a username login from a banned account', async () => {
await seedAccount(6102, 'BannedByName')
await banAccount(6102)
const res = await postToken(
`grant_type=password&username=BannedByName&password=${LOGIN_PASSWORD}`
)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A client that was already signed in when the ban landed still holds a valid refresh
// token; redeeming it must not renew the session.
test('POST /connect/token refuses to refresh a banned accounts session', async () => {
await seedAccount(6103, 'BannedLater')
const login = await postToken(`account_id=6103&password=${LOGIN_PASSWORD}`)
expect(login.status).toBe(200)
const refreshToken = login.json.refresh_token as string
await banAccount(6103)
const refreshed = await postToken(
`grant_type=refresh_token&refresh_token=${encodeURIComponent(refreshToken)}`
)
expect(refreshed.status).toBe(400)
expect(refreshed.json.error_description).toBe('this account is banned')
})
// The ban check runs AFTER the credential check, so a wrong password on a banned
// account still answers the ordinary bad-credential refusal — it can't be used to
// find out whether an account exists or is banned without knowing its password.
test('a wrong password on a banned account is still a credential refusal', async () => {
await seedAccount(6104, 'BannedWrongPw')
await banAccount(6104)
const res = await postToken('account_id=6104&password=not-the-password')
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('invalid account_id or password')
})
// A timed ban lifts itself when its expiry passes; nothing clears the flag.
test('an expired ban lets the account sign in again', async () => {
await seedAccount(6105, 'ServedTime')
await banAccount(6105, '2020-01-01T00:00:00.000Z')
const res = await postToken(`account_id=6105&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
expect(decodePayload(res.json.access_token as string).sub).toBe('6105')
})
test('a ban that has not expired yet still refuses the login', async () => {
await seedAccount(6106, 'StillServing')
await banAccount(6106, new Date(Date.now() + 3_600_000).toISOString())
const res = await postToken(`account_id=6106&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(400)
expect(res.json.error_description).toBe('this account is banned')
})
// A report is not a ban until a moderator converts it.
test('an unbanned report does not refuse the login', async () => {
await seedAccount(6107, 'MerelyReported')
await createReport(env.DB, { reporterPlayerId: 1, reportedPlayerId: 6107 })
const res = await postToken(`account_id=6107&password=${LOGIN_PASSWORD}`)
expect(res.status).toBe(200)
})
// The ban is the ACCOUNT's: nothing here stops the player signing up again, which is
// the signup caps' job, not this check's.
test('a banned player can still create a new account', async () => {
await seedAccount(6108, 'BannedButNew')
await banAccount(6108)
const created = await postToken('grant_type=create_account&platform_id=steam-after-ban')
expect(created.status).toBe(200)
})
})