mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[auth] device id limits
This commit is contained in:
@@ -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
@@ -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)
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user