[auth] device id limits

This commit is contained in:
Devin Zuczek
2026-09-27 22:20:45 -04:00
parent 91db9ff82b
commit b090dc105a
5 changed files with 136 additions and 16 deletions
+5
View File
@@ -62,8 +62,13 @@ RECFLARE_DOMAIN=rec.example.com
# ...PER_IP counts accounts per signup IP — coarse, since a household, NAT or campus
# network shares one address. This is the one to raise (or zero out) if real players
# report being locked out.
# ...PER_DEVICE_ID counts accounts per client device id (the game's per-install hash,
# sent as `device_id`) — narrower than an IP and unmoved by a VPN, which is what the
# IP cap alone can't stop. Client-chosen, so it catches the stock client, not a script.
# Signups that post no device id (the website's) aren't counted.
# RECFLARE_MAX_ACCOUNTS_PER_PLATFORM_ID=3
# RECFLARE_MAX_ACCOUNTS_PER_IP=3
# RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3
# How far a ban reaches beyond the account it was handed to (`match` and `auth`), as a
# comma-separated list out of `ip` and `platform` — or `off` for neither. Unset means
+40 -12
View File
@@ -4,6 +4,7 @@ import { useWorkersLogger } from 'workers-tagged-logger'
import { z } from 'zod'
import {
countAccountsByDeviceId,
countAccountsBySignupIp,
createAccount,
GAME_VERSION,
@@ -116,24 +117,32 @@ const FAKE_OCULUS_CACHED_LOGIN = {
* Signup caps, enforced on create_account only (never on login — an existing account
* always stays reachable, however many accounts its owner has since accumulated).
*
* Two independent arms, because they fail in opposite ways:
* Three independent arms, because they fail in different ways:
* - Per verified platform id (a Steam-proven SteamID64). The sharp one: it can't be
* spoofed and can't be reset by changing networks. Only binds on a platform
* create_account — the password/anonymous path has no platform identity to count,
* so the IP arm is the only thing standing between it and bulk signup.
* so the other two arms are all that stands between it and bulk signup.
* - Per signup IP. Coarse: households, NAT and shared campus/mobile networks put many
* legitimate players behind one address, so this WILL be the arm that produces false
* positives. It counts `signupIp` (immutable), so an abuser can't reset their own
* count by hopping networks.
* count by hopping networks — but a VPN gives them a fresh counter per exit, which
* is exactly how one player made 150 accounts, six per address, in a weekend.
* - Per client device id. Narrower than an IP (a household shares an address, not an
* install) and stable across VPN hops, which is the hole in the IP arm it covers. It
* counts the last-seen `deviceId`, which a login moves; see the field's notes in
* accounts-db for why an immutable copy isn't worth keeping. Client-chosen and
* unverified, so it catches a stock client, not a script that fabricates ids. Skipped
* when the signup posts no `device_id` (the website's don't).
*
* These are the defaults. An operator overrides either arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
* setting one to 0 disables that arm entirely — which a small private server that trusts
* everyone it invites will want, and which the IP arm in particular is worth reaching for
* if a shared network is being locked out.
* These are the defaults. An operator overrides any arm with the matching worker var
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`
* in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a
* small private server that trusts everyone it invites will want, and which the IP arm in
* particular is worth reaching for if a shared network is being locked out.
*/
const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const DEFAULT_MAX_ACCOUNTS_PER_IP = 3
const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
@@ -608,9 +617,10 @@ const app = new Hono<App>()
'',
'**`create_account`** — mints a new account with an auto-assigned random username',
'(players do not pick one initially) and places it in the Orientation room. A posted',
'`password` becomes the login credential. Subject to two independent signup caps,',
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
'`password` becomes the login credential. Subject to three independent signup caps,',
'per verified platform id, per signup IP and per client `device_id`',
'(`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`;',
'any disabled by setting it to 0). If it asserts a',
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
'must verify.',
'',
@@ -872,7 +882,7 @@ const app = new Hono<App>()
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when it's disabled (var <= 0) or when its
// identity is unknown (no verified platform id / no client IP) — an unattributable
// identity is unknown (no verified platform id / no client IP / no device id) — an unattributable
// signup can't be counted against anyone, and lumping them together would lock out
// real players. The disabled check comes first so a disabled arm costs no D1 read.
const maxPerPlatformId = intVar(
@@ -880,6 +890,10 @@ const app = new Hono<App>()
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
)
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
const maxPerDeviceId = intVar(
c.env.MAX_ACCOUNTS_PER_DEVICE_ID,
DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID
)
if (
maxPerPlatformId > 0 &&
verifiedPlatformId !== null &&
@@ -914,6 +928,20 @@ const app = new Hono<App>()
400
)
}
if (
maxPerDeviceId > 0 &&
deviceId !== '' &&
(await countAccountsByDeviceId(c.env.DB, deviceId)) >= maxPerDeviceId
) {
logger.info('signup rejected: per-device account limit', { deviceId, ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this device',
},
400
)
}
// Bind the platform identity ONLY when the platform proved it (a Steam ticket or
// a Meta-validated nonce). A password/anonymous create_account (no platform)
+1
View File
@@ -26,6 +26,7 @@ export type Env = SharedHonoEnv & {
// read them through `intVar`, never as a bare number.
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
MAX_ACCOUNTS_PER_IP?: string | number
MAX_ACCOUNTS_PER_DEVICE_ID?: string | number
/**
* Which linked arms a ban is enforced through, as a comma-separated list out of `ip`
* and `platform` — or `off` for neither. Unset means BOTH: a ban reaches the accounts
@@ -1035,6 +1035,47 @@ describe('auth worker routes', () => {
expect(other.status).toBe(200)
})
test('POST /connect/token caps the accounts created from one device', async () => {
// One install, many networks: the VPN-hopping signup the IP cap can't see. Each
// signup posts a fresh IP so only the device arm can be what refuses it.
const deviceId = 'dev-capped-install'
for (let i = 0; i < 3; i++) {
const ok = await postToken(
`grant_type=create_account&platform_id=steam-devcap${i}&device_id=${deviceId}&device_class=2`,
`198.51.100.${40 + i}`
)
expect(ok.status).toBe(200)
}
// The 4th signup from that device is refused — the cap is 3.
const capped = await postToken(
`grant_type=create_account&platform_id=steam-devcap3&device_id=${deviceId}&device_class=2`,
'198.51.100.43'
)
expect(capped.status).toBe(400)
expect(capped.json.error).toBe('invalid_grant')
expect(capped.json.error_description).toMatch(/device/)
// Another device is unaffected, and a signup that posts no device id (the website's)
// can't be attributed to one, so the arm skips it rather than lumping it in.
const other = await postToken(
'grant_type=create_account&platform_id=steam-devcap4&device_id=dev-other-install&device_class=2',
'198.51.100.44'
)
expect(other.status).toBe(200)
const deviceless = await postToken(
'grant_type=create_account&platform_id=steam-devcap5',
'198.51.100.45'
)
expect(deviceless.status).toBe(200)
// The capped device can still LOG IN to what it has — the cap is on signup only.
const login = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}&device_id=${deviceId}&device_class=2`,
'198.51.100.46'
)
expect(login.status).toBe(200)
})
test('the signup caps come from vars, and 0 disables an arm', async () => {
// The cap an operator actually runs is the `MAX_ACCOUNTS_PER_IP` var; the constant in
// auth.app.ts is only the fallback. `env` is shared by every test in this file, so the
@@ -1056,6 +1097,24 @@ describe('auth worker routes', () => {
} finally {
env.MAX_ACCOUNTS_PER_IP = original
}
// The device arm reads its own var the same way.
const originalDevice = env.MAX_ACCOUNTS_PER_DEVICE_ID
try {
env.MAX_ACCOUNTS_PER_DEVICE_ID = 1
const device = '&device_id=dev-var-install&device_class=2'
const first = await postToken(`grant_type=create_account&platform_id=steam-dvar0${device}`)
expect(first.status).toBe(200)
const capped = await postToken(`grant_type=create_account&platform_id=steam-dvar1${device}`)
expect(capped.status).toBe(400)
expect(capped.json.error_description).toMatch(/device/)
env.MAX_ACCOUNTS_PER_DEVICE_ID = 0
const uncapped = await postToken(`grant_type=create_account&platform_id=steam-dvar2${device}`)
expect(uncapped.status).toBe(200)
} finally {
env.MAX_ACCOUNTS_PER_DEVICE_ID = originalDevice
}
})
test('POST /connect/token does not cap logins, only signups', async () => {
+31 -4
View File
@@ -76,14 +76,18 @@ export interface Account {
*
* Kept for ban EVASION: an account logging in from the same install as a banned one is
* the sharpest of the linked arms after a proven platform identity, and much narrower
* than the IP arm (a household shares an address, not an install). Nothing consumes it
* yet — see `getAccountsByDeviceId`, and the arms in the api worker's bans-db, which
* this is not one of yet.
* than the IP arm (a household shares an address, not an install). The per-device
* signup cap counts it (`countAccountsByDeviceId`); the ban-evasion arms in the api
* worker's bans-db do not yet.
*
* ONE value, overwritten each login: this is the account's LAST-SEEN device, not a
* history of every device it has used. So evasion matching on it will catch a player
* who evades from the install they were last banned on, and miss one who has since
* logged the banned account in somewhere else.
* logged the banned account in somewhere else — and the signup cap can be walked back
* the same way, by logging old accounts in from another install. That costs the
* abuser a login per freed slot, which is the point; and since the id is client-chosen,
* a scripted signup can fabricate one anyway, so an immutable signup-time copy (as the
* IP arm keeps in `signupIp`) would buy nothing here.
*/
deviceId?: string
/** DeviceClass int (2 = PC/standalone) that `deviceId` was last seen on. */
@@ -301,6 +305,29 @@ export async function getAccountsByDeviceId(db: D1Database, deviceId: string): P
return parseAll(results)
}
/**
* How many accounts were last seen on a given client device — the count the per-device
* signup cap ("no more than N accounts per install") is enforced against. Counts the
* last-seen `deviceId`, the only device the account records (see the field's notes for
* why that is enough here).
*
* An empty id counts 0, as `countAccountsBySignupIp` does for an empty ip: a signup that
* posts no `device_id` (the website's, for one) can't be attributed to an install, and
* lumping every such account together would lock out real players.
*
* Narrower than an IP — a household shares an address, not an install — but still an
* unverified client value, so a cap here catches the stock client making accounts, not
* a script that fabricates ids.
*/
export async function countAccountsByDeviceId(db: D1Database, deviceId: string): Promise<number> {
if (deviceId === '') return 0
const row = await db
.prepare("SELECT COUNT(*) AS n FROM account WHERE json_extract(data, '$.deviceId') = ?1")
.bind(deviceId)
.first<{ n: number }>()
return row?.n ?? 0
}
/** Record the account's most recent successful login time (ISO-8601). */
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
await db