mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-27 07:58:55 -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
|
# ...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
|
# network shares one address. This is the one to raise (or zero out) if real players
|
||||||
# report being locked out.
|
# 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_PLATFORM_ID=3
|
||||||
# RECFLARE_MAX_ACCOUNTS_PER_IP=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
|
# 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
|
# 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 { z } from 'zod'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
countAccountsByDeviceId,
|
||||||
countAccountsBySignupIp,
|
countAccountsBySignupIp,
|
||||||
createAccount,
|
createAccount,
|
||||||
GAME_VERSION,
|
GAME_VERSION,
|
||||||
@@ -116,24 +117,32 @@ const FAKE_OCULUS_CACHED_LOGIN = {
|
|||||||
* Signup caps, enforced on create_account only (never on login — an existing account
|
* Signup caps, enforced on create_account only (never on login — an existing account
|
||||||
* always stays reachable, however many accounts its owner has since accumulated).
|
* 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
|
* - 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
|
* 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,
|
* 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
|
* - 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
|
* 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
|
* 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
|
* These are the defaults. An operator overrides any arm with the matching worker var
|
||||||
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` in wrangler.jsonc `vars`), and
|
* (`MAX_ACCOUNTS_PER_PLATFORM_ID` / `MAX_ACCOUNTS_PER_IP` / `MAX_ACCOUNTS_PER_DEVICE_ID`
|
||||||
* setting one to 0 disables that arm entirely — which a small private server that trusts
|
* in wrangler.jsonc `vars`), and setting one to 0 disables that arm entirely — which a
|
||||||
* everyone it invites will want, and which the IP arm in particular is worth reaching for
|
* small private server that trusts everyone it invites will want, and which the IP arm in
|
||||||
* if a shared network is being locked out.
|
* 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_PLATFORM_ID = 3
|
||||||
const DEFAULT_MAX_ACCOUNTS_PER_IP = 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. */
|
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
|
||||||
const ORIENTATION_ROOM_ID = 13
|
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',
|
'**`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',
|
'(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,',
|
'`password` becomes the login credential. Subject to three independent signup caps,',
|
||||||
'per verified platform id and per signup IP (`MAX_ACCOUNTS_PER_PLATFORM_ID` /',
|
'per verified platform id, per signup IP and per client `device_id`',
|
||||||
'`MAX_ACCOUNTS_PER_IP`; either disabled by setting it to 0). If it asserts a',
|
'(`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`',
|
'`platform`, that platform must be verifiable (Steam or Meta) and its `platform_auth`',
|
||||||
'must verify.',
|
'must verify.',
|
||||||
'',
|
'',
|
||||||
@@ -872,7 +882,7 @@ const app = new Hono<App>()
|
|||||||
|
|
||||||
// Signup caps. Checked before minting anything, so a rejected signup leaves no
|
// 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
|
// 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
|
// 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.
|
// real players. The disabled check comes first so a disabled arm costs no D1 read.
|
||||||
const maxPerPlatformId = intVar(
|
const maxPerPlatformId = intVar(
|
||||||
@@ -880,6 +890,10 @@ const app = new Hono<App>()
|
|||||||
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID
|
||||||
)
|
)
|
||||||
const maxPerIp = intVar(c.env.MAX_ACCOUNTS_PER_IP, DEFAULT_MAX_ACCOUNTS_PER_IP)
|
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 (
|
if (
|
||||||
maxPerPlatformId > 0 &&
|
maxPerPlatformId > 0 &&
|
||||||
verifiedPlatformId !== null &&
|
verifiedPlatformId !== null &&
|
||||||
@@ -914,6 +928,20 @@ const app = new Hono<App>()
|
|||||||
400
|
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
|
// 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)
|
// 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.
|
// read them through `intVar`, never as a bare number.
|
||||||
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
|
MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number
|
||||||
MAX_ACCOUNTS_PER_IP?: 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`
|
* 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
|
* 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)
|
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 () => {
|
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
|
// 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
|
// 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 {
|
} finally {
|
||||||
env.MAX_ACCOUNTS_PER_IP = original
|
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 () => {
|
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
|
* 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
|
* 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
|
* than the IP arm (a household shares an address, not an install). The per-device
|
||||||
* yet — see `getAccountsByDeviceId`, and the arms in the api worker's bans-db, which
|
* signup cap counts it (`countAccountsByDeviceId`); the ban-evasion arms in the api
|
||||||
* this is not one of yet.
|
* worker's bans-db do not yet.
|
||||||
*
|
*
|
||||||
* ONE value, overwritten each login: this is the account's LAST-SEEN device, not a
|
* 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
|
* 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
|
* 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
|
deviceId?: string
|
||||||
/** DeviceClass int (2 = PC/standalone) that `deviceId` was last seen on. */
|
/** 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)
|
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). */
|
/** Record the account's most recent successful login time (ISO-8601). */
|
||||||
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
export async function setLastLoginTime(db: D1Database, id: number, time: string): Promise<void> {
|
||||||
await db
|
await db
|
||||||
|
|||||||
Reference in New Issue
Block a user