mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[rooms] improve room ban/unban, history
[api] mod kick [match] allow banned users into dorm
This commit is contained in:
+42
-43
@@ -72,8 +72,8 @@ 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 grant is refused with when the caller's account is not itself
|
||||
* banned but shares an identity with one that is (see bans-db's linked arms). A fixed
|
||||
* The `error_description` a SIGNUP is refused with when it shares an identity with a banned
|
||||
* account (see bans-db's linked arms). A fixed
|
||||
* sentence, 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. Deliberately vague: the account
|
||||
@@ -81,7 +81,9 @@ const TOKEN_SCOPE =
|
||||
* account is banned" would be a lie, and naming the account we matched them to would hand
|
||||
* out somebody else's moderation record.
|
||||
*
|
||||
* A DIRECTLY banned account is not refused here at all — see the token grant.
|
||||
* Only `create_account` is refused this way. SIGNING IN is never refused for a ban, its own
|
||||
* or a linked one — see the token grant: the client needs a token to reach the block screen,
|
||||
* and `match` is what holds a blocked player to their dorm.
|
||||
*/
|
||||
const BLOCKED_DESCRIPTION = 'this device or network is blocked'
|
||||
|
||||
@@ -651,22 +653,25 @@ const app = new Hono<App>()
|
||||
'the `rn.privilege` CLAIM (`BanVChat`, `BanRmChat`) — scope-shaped name, but the',
|
||||
'client reads it as a claim beside `role`, and it is absent for everyone else.',
|
||||
'',
|
||||
'**Bans.** A BANNED account still gets a token — every grant, including a refresh.',
|
||||
'**Bans.** A BLOCKED account still gets a token — every grant, including a refresh,',
|
||||
'and whether the ban is its own or reaches it through the evasion arms.',
|
||||
'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 token',
|
||||
'is what lets the client reach `api`’s `/api/PlayerReporting/v1/moderationBlockDetails`',
|
||||
'and show the player the block screen that explains the ban; the ban itself is',
|
||||
'enforced by `match`, which refuses every matchmake for a banned player, so a token',
|
||||
'gets them as far as that screen and no further.',
|
||||
'enforced by `match`, which refuses every matchmake but the caller’s own dorm, so a',
|
||||
'token gets them as far as that screen and no further.',
|
||||
'',
|
||||
'What IS refused here (`invalid_grant`) is ban EVASION: an account that shares a',
|
||||
'PROVEN platform identity (a `platform_account` link) or an IP (`signupIp`/',
|
||||
'`lastLoginIp`, or the address this request came from) with a banned one, and a',
|
||||
'`create_account` carrying either, which is refused BEFORE it mints anything. Such',
|
||||
'an account has no ban of its own for the block screen to describe, so there is',
|
||||
'nothing to let it in for. Those two arms are the operator’s `BAN_EVASION_MATCH`',
|
||||
'knob (`ip`, `platform`, or `off`). The description is deliberately vague — the',
|
||||
'account refused may belong to a housemate of the banned player rather than to them.',
|
||||
'Ban EVASION — an account sharing a PROVEN platform identity (a `platform_account`',
|
||||
'link) or an IP (`signupIp`/`lastLoginIp`, or the address this request came from)',
|
||||
'with a banned one — signs in the same way, and is stopped at matchmaking like any',
|
||||
'other blocked account. Those two arms are the operator’s `BAN_EVASION_MATCH` knob',
|
||||
'(`ip`, `platform`, or `off`).',
|
||||
'',
|
||||
'What IS refused here (`invalid_grant`) is a `create_account` carrying either link,',
|
||||
'refused BEFORE it mints anything: making a NEW account to evade with is a different',
|
||||
'act from signing into one. The description is deliberately vague — the signup',
|
||||
'refused may belong to a housemate of the banned player rather than to them.',
|
||||
].join('\n'),
|
||||
requestBody: form(
|
||||
TokenRequest,
|
||||
@@ -1033,33 +1038,36 @@ const app = new Hono<App>()
|
||||
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
|
||||
}
|
||||
|
||||
// A DIRECTLY banned account still gets its token. The client needs one to reach
|
||||
// `api`'s moderationBlockDetails, which is where the player is TOLD they are banned
|
||||
// (category, time left, "Rule violation") — refused here, they would only ever see
|
||||
// a failed sign-in. The ban is enforced by matchmaking instead, which refuses every
|
||||
// matchmake for a banned player, so the token gets them as far as the block screen
|
||||
// and no further. Logged, so the operator can see a banned player signing in.
|
||||
// EVERY account gets its token, banned or not — a ban of its own, or a link to
|
||||
// somebody else's through the evasion arms. The client needs one to reach `api`'s
|
||||
// moderationBlockDetails, which is where a banned player is TOLD they are banned
|
||||
// (category, time left, "Rule violation"); refused here they would only ever see a
|
||||
// sign-in that fails, with nothing saying why. The ban is enforced by `match`, which
|
||||
// refuses every matchmake but the caller's own dorm, so a token gets them as far as
|
||||
// the block screen and no further.
|
||||
//
|
||||
// Ban EVASION is still refused here: an account that merely shares a device or
|
||||
// network with a banned one has no ban of its own for that screen to describe, so
|
||||
// there is nothing to let it in for — and letting it in is exactly what the evader
|
||||
// wants. 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.
|
||||
// That includes ban EVASION, which this used to refuse. An evader signing in lands
|
||||
// in their dorm and can enter no room — the same place the ban leaves them — and
|
||||
// refusing the token instead only ever produced a client stuck on a failed login.
|
||||
// `create_account` is still refused before it mints anything (above): that is making
|
||||
// a NEW account to evade with, which is a different act from signing into one.
|
||||
//
|
||||
// Logged either way, and the arm is logged with it: "banned" and "shares a network
|
||||
// with somebody banned" are very different things to be reading in a log.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// The request's own IP and proven identity are passed alongside the account, so a
|
||||
// ban also reaches an old, clean account logged into from the banned player's
|
||||
// device or network — the stored ips alone would only catch that on the SECOND
|
||||
// login. create_account was already refused before it minted anything (above);
|
||||
// this still runs for it, so a signup that raced one is refused too.
|
||||
// ban also reaches an old, clean account logged into from the banned player's device
|
||||
// or network — the stored ips alone would only catch that on the SECOND login.
|
||||
const ban = await resolveBan(c.env.DB, Number(accountId), {
|
||||
identity: { ip: clientIp, platform: verifiedPlatform, platformId: verifiedPlatformId },
|
||||
arms: banEvasionMatch(c.env.BAN_EVASION_MATCH),
|
||||
})
|
||||
if (ban && ban.via !== 'account') {
|
||||
logger.info('token refused: ban evasion', {
|
||||
if (ban) {
|
||||
logger.info('token issued to a blocked account', {
|
||||
accountId,
|
||||
grantType,
|
||||
via: ban.via,
|
||||
@@ -1067,15 +1075,6 @@ const app = new Hono<App>()
|
||||
reportId: ban.ban.id,
|
||||
banExpires: ban.ban.ban_expires,
|
||||
})
|
||||
return c.json({ error: 'invalid_grant', error_description: BLOCKED_DESCRIPTION }, 400)
|
||||
}
|
||||
if (ban) {
|
||||
logger.info('token issued to banned account', {
|
||||
accountId,
|
||||
grantType,
|
||||
reportId: ban.ban.id,
|
||||
banExpires: ban.ban.ban_expires,
|
||||
})
|
||||
}
|
||||
|
||||
// Never sign with an empty key. An empty JWT_SECRET (misconfigured/missing
|
||||
|
||||
@@ -1662,12 +1662,12 @@ describe('banned accounts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// The ban follows the player past the account it was written on: a login from an account
|
||||
// that shares a proven platform identity or an IP with a banned one is refused, and a
|
||||
// signup carrying either is refused before it mints anything. Unlike the banned account
|
||||
// itself, such an account has no ban of its own for the block screen to describe, so
|
||||
// there is nothing to let it in for. See the api worker's bans-db.ts for the arms and
|
||||
// the BAN_EVASION_MATCH knob.
|
||||
// The ban follows the player past the account it was written on. SIGNING IN is not where
|
||||
// that is enforced: a linked account gets its token like any other, and `match` holds it to
|
||||
// its own dorm — refusing the token only ever left the client stuck on a failed login. What
|
||||
// IS refused here is a SIGNUP carrying either link, before it mints anything: making a new
|
||||
// account to evade with is a different act from signing into one. See the api worker's
|
||||
// bans-db.ts for the arms and the BAN_EVASION_MATCH knob.
|
||||
describe('ban evasion at the token endpoint', () => {
|
||||
/** Seed a loginable account carrying the IPs it signed up / last logged in from. */
|
||||
const account = async (id: number, name: string, ips: Record<string, string> = {}) => {
|
||||
@@ -1686,38 +1686,38 @@ describe('ban evasion at the token endpoint', () => {
|
||||
const login = (id: number, ip?: string) =>
|
||||
postToken(`account_id=${id}&password=${LOGIN_PASSWORD}`, ip)
|
||||
|
||||
test('an account sharing a banned account’s platform identity cannot log in', async () => {
|
||||
test('an account sharing a banned account’s platform identity still signs in', async () => {
|
||||
await account(6301, 'EvaderOne')
|
||||
await linkPlatformIdentity(env.DB, 6301, 0, 'steam-tokenevader')
|
||||
await banAccount(6301)
|
||||
await account(6302, 'EvaderTwo')
|
||||
await linkPlatformIdentity(env.DB, 6302, 0, 'steam-tokenevader')
|
||||
|
||||
// A token, like anyone else: the client has to load in before it can be told anything,
|
||||
// and `match` is what keeps this account out of every room but its dorm.
|
||||
const res = await login(6302)
|
||||
expect(res.status).toBe(400)
|
||||
// A vaguer sentence than a direct ban: this account may belong to somebody else.
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
expect(res.status).toBe(200)
|
||||
expect(decodePayload(res.json.access_token as string).sub).toBe('6302')
|
||||
})
|
||||
|
||||
test('an account sharing a banned account’s IP cannot log in', async () => {
|
||||
test('an account sharing a banned account’s IP still signs in', async () => {
|
||||
await account(6303, 'SameHouseBanned', { signupIp: '203.0.113.30' })
|
||||
await banAccount(6303)
|
||||
await account(6304, 'SameHouseClean', { signupIp: '203.0.113.30' })
|
||||
|
||||
const res = await login(6304)
|
||||
expect(res.status).toBe(400)
|
||||
expect(res.json.error_description).toBe('this device or network is blocked')
|
||||
expect((await login(6304)).status).toBe(200)
|
||||
})
|
||||
|
||||
// The address the request arrives from counts, so an account that never logged in
|
||||
// from the banned network before is caught on the first attempt rather than the second.
|
||||
test('the request’s own IP is matched even when the account has none stored', async () => {
|
||||
// The address the request arrives from counts, so a SIGNUP from the banned network is
|
||||
// caught on the first attempt rather than the second.
|
||||
test('the request’s own IP is matched at signup even when the account has none stored', async () => {
|
||||
await account(6305, 'BannedAtHome', { signupIp: '203.0.113.31' })
|
||||
await banAccount(6305)
|
||||
await account(6306, 'CleanElsewhere')
|
||||
|
||||
expect((await login(6306, '203.0.113.31')).status).toBe(400)
|
||||
// The same account from any other network signs in normally.
|
||||
expect((await postToken('grant_type=create_account', '203.0.113.31')).status).toBe(400)
|
||||
// Signing in from that same network is not refused — only minting a new account is.
|
||||
expect((await login(6306, '203.0.113.31')).status).toBe(200)
|
||||
expect((await login(6306, '198.51.100.31')).status).toBe(200)
|
||||
})
|
||||
|
||||
@@ -1746,7 +1746,8 @@ describe('ban evasion at the token endpoint', () => {
|
||||
expect(res.status).toBe(200)
|
||||
})
|
||||
|
||||
// The knob an operator reaches for when the IP arm locks out real players.
|
||||
// The knob an operator reaches for when the IP arm locks out real players. It governs the
|
||||
// linked arms wherever they are applied — at signup here, and at every matchmake in `match`.
|
||||
test('BAN_EVASION_MATCH=platform drops the IP arm but keeps the platform one', async () => {
|
||||
const original = env.BAN_EVASION_MATCH
|
||||
await account(6320, 'KnobBanned', { signupIp: '203.0.113.50' })
|
||||
@@ -1758,16 +1759,22 @@ describe('ban evasion at the token endpoint', () => {
|
||||
|
||||
try {
|
||||
env.BAN_EVASION_MATCH = 'platform'
|
||||
expect((await login(6321)).status).toBe(200)
|
||||
expect((await login(6322)).status).toBe(400)
|
||||
// And signup from that network is open again.
|
||||
// Signup from the banned IP is open again, since that arm is off...
|
||||
expect((await postToken('grant_type=create_account', '203.0.113.50')).status).toBe(200)
|
||||
// ...while the platform arm still refuses one from the banned headset.
|
||||
expect(
|
||||
(await postToken('grant_type=create_account&platform=0&platform_id=steam-knobevader'))
|
||||
.status
|
||||
).toBe(400)
|
||||
|
||||
env.BAN_EVASION_MATCH = 'off'
|
||||
expect((await login(6322)).status).toBe(200)
|
||||
// The banned account itself signs in whatever the knob says — its ban is
|
||||
// enforced at matchmake, and the knob only governs the linked arms.
|
||||
expect((await login(6320)).status).toBe(200)
|
||||
// Signing in is not what the knob gates: every one of these gets a token whatever it
|
||||
// says — the housemate, the evader, and the banned account itself.
|
||||
for (const arms of ['platform', 'off']) {
|
||||
env.BAN_EVASION_MATCH = arms
|
||||
for (const id of [6320, 6321, 6322]) {
|
||||
expect((await login(id)).status, `${arms}/${id}`).toBe(200)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
env.BAN_EVASION_MATCH = original
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user