add an account limit

This commit is contained in:
Devin Zuczek
2026-07-14 11:49:47 -04:00
parent deffa1bfc5
commit 9d0d00992f
4 changed files with 216 additions and 28 deletions
+61 -3
View File
@@ -2,14 +2,16 @@ import { Hono } from 'hono'
import { useWorkersLogger } from 'workers-tagged-logger'
import {
countAccountsByPlatformId,
countAccountsBySignupIp,
createAccount,
getAccount,
getAccountByUsername,
getAccountsByPlatformId,
getPasswordHash,
RoomInstanceType,
setDeviceInfo,
setLastLoginTime,
setLoginContext,
setPasswordHash,
setPresence,
} from '@repo/domain'
@@ -42,6 +44,23 @@ const PLATFORM_TYPES: Record<number, string> = {
8: 'Pico',
}
/**
* 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:
* - 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.
* - 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.
*/
const MAX_ACCOUNTS_PER_PLATFORM_ID = 3
const MAX_ACCOUNTS_PER_IP = 3
/** New players start in the Orientation room (RoomId 13) — the new-user flow. */
const ORIENTATION_ROOM_ID = 13
/**
@@ -227,6 +246,12 @@ const app = new Hono<App>()
typeof body.device_class === 'string' ? Number.parseInt(body.device_class, 10) : NaN
const deviceClass = Number.isNaN(deviceClassInt) ? 0 : deviceClassInt
// The client's real IP, per Cloudflare (the client can't spoof CF-Connecting-IP —
// the edge sets it — unlike X-Forwarded-For, which is why we don't read that).
// Recorded as the immutable `signupIp` at creation and as `lastLoginIp` on every
// login; both feed the per-IP signup cap. Absent (empty) outside the CF edge.
const clientIp = c.req.header('cf-connecting-ip') ?? ''
// A platform-authenticated login proves who you are with the platform itself,
// and we can ONLY verify Steam (platform 0) — via its Steam-signed platform_auth
// ticket. So those logins must be Steam:
@@ -275,6 +300,37 @@ const app = new Hono<App>()
// via create_account or /account/me/changepassword.
let accountId: string
if (grantType === 'create_account') {
// Signup caps. Checked before minting anything, so a rejected signup leaves no
// account behind. Each arm is skipped when its identity is unknown (no verified
// platform id / no client IP) — an unattributable signup can't be counted against
// anyone, and lumping them together would lock out real players.
if (
verifiedSteamId !== null &&
(await countAccountsByPlatformId(c.env.DB, verifiedSteamId)) >= MAX_ACCOUNTS_PER_PLATFORM_ID
) {
logger.info('signup rejected: platform account limit', { platformId: verifiedSteamId })
return c.json(
{
error: 'invalid_grant',
error_description: 'account limit reached for this platform account',
},
400
)
}
if (
clientIp !== '' &&
(await countAccountsBySignupIp(c.env.DB, clientIp)) >= MAX_ACCOUNTS_PER_IP
) {
logger.info('signup rejected: per-IP account limit', { ip: clientIp })
return c.json(
{
error: 'invalid_grant',
error_description: 'too many accounts created from this network',
},
400
)
}
// Bind the platform identity ONLY when a Steam ticket proved it. That bound
// `platformId` (the SteamID64) is what a later cached login is checked against,
// so only this Steam user can log back into the account. A password/anonymous
@@ -286,6 +342,8 @@ const app = new Hono<App>()
lastLoginTime: new Date().toISOString(),
deviceId: deviceId || undefined,
deviceClass: deviceId ? deviceClass : undefined,
signupIp: clientIp || undefined,
lastLoginIp: clientIp || undefined,
})
accountId = String(account.accountId)
// Establish the login password when one is posted (raw password never stored).
@@ -332,7 +390,7 @@ const app = new Hono<App>()
}
accountId = String(account.accountId)
await setLastLoginTime(c.env.DB, account.accountId, new Date().toISOString())
await setDeviceInfo(c.env.DB, account.accountId, deviceId, deviceClass)
await setLoginContext(c.env.DB, account.accountId, { deviceId, deviceClass, ip: clientIp })
} else {
// Resolve the account from a posted numeric `account_id` or, as RecRoom's
// password grant sends, a `username` (case-insensitive; trailing whitespace
@@ -364,7 +422,7 @@ const app = new Hono<App>()
}
accountId = String(resolvedId)
await setLastLoginTime(c.env.DB, resolvedId, new Date().toISOString())
await setDeviceInfo(c.env.DB, resolvedId, deviceId, deviceClass)
await setLoginContext(c.env.DB, resolvedId, { deviceId, deviceClass, ip: clientIp })
}
const accessToken = await generateToken(
+69 -12
View File
@@ -69,24 +69,29 @@ function decodePayload(token: string): Record<string, unknown> {
) as Record<string, unknown>
}
async function accessTokenFor(body: string): Promise<string> {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body,
})
return ((await res.json()) as { access_token: string }).access_token
async function accessTokenFor(body: string, ip?: string): Promise<string> {
return (await postToken(body, ip)).json.access_token as string
}
async function tokenFor(body: string): Promise<Record<string, unknown>> {
return decodePayload(await accessTokenFor(body))
async function tokenFor(body: string, ip?: string): Promise<Record<string, unknown>> {
return decodePayload(await accessTokenFor(body, ip))
}
/** POST a form-urlencoded body to /connect/token, returning status + parsed JSON. */
async function postToken(body: string): Promise<{ status: number; json: Record<string, unknown> }> {
/**
* POST a form-urlencoded body to /connect/token, returning status + parsed JSON.
* `ip` sets CF-Connecting-IP (what Cloudflare's edge sets in production); omit it and
* the request looks IP-less, which is how the other tests dodge the per-IP signup cap.
*/
async function postToken(
body: string,
ip?: string
): Promise<{ status: number; json: Record<string, unknown> }> {
const res = await exports.default.fetch(`${ORIGIN}/connect/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(ip ? { 'CF-Connecting-IP': ip } : {}),
},
body,
})
return { status: res.status, json: (await res.json()) as Record<string, unknown> }
@@ -362,6 +367,20 @@ describe('auth worker routes', () => {
expect(shared.map((a) => a.accountId)).toContain(sub)
})
test('POST /connect/token stores deviceClass as an integer, not a REAL', async () => {
// D1 binds a JS number as a SQLite REAL, so a naive json_set writes `"deviceClass":2.0`
// into the blob. JSON.parse tolerates that, but the raw JSON is what other readers
// (and any strict int parser) see, so assert on the stored TEXT, not the parsed value.
await postToken(
`grant_type=password&username=Player77&password=${LOGIN_PASSWORD}&device_id=dev-int&device_class=2`
)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(77)
.first<{ data: string }>()
expect(row!.data).toContain('"deviceClass":2')
expect(row!.data).not.toContain('2.0')
})
test('POST /connect/token refreshes the stored device on a credential login', async () => {
// Account 42 was seeded with no device; a later login records the one it came from.
const res = await postToken(
@@ -376,6 +395,44 @@ describe('auth worker routes', () => {
expect(account.deviceClass).toBe(3)
})
test('POST /connect/token create_account records the client IP', async () => {
const payload = await tokenFor('grant_type=create_account&platform_id=steam-ip1', '203.0.113.7')
const sub = Number.parseInt(payload.sub as string, 10)
const row = await env.DB.prepare('SELECT data FROM account WHERE account_id = ?1')
.bind(sub)
.first<{ data: string }>()
const account = JSON.parse(row!.data) as { signupIp: string; lastLoginIp: string }
expect(account.signupIp).toBe('203.0.113.7')
expect(account.lastLoginIp).toBe('203.0.113.7')
})
test('POST /connect/token caps the accounts created from one IP', async () => {
const ip = '198.51.100.22'
for (let i = 0; i < 3; i++) {
const ok = await postToken(`grant_type=create_account&platform_id=steam-cap${i}`, ip)
expect(ok.status).toBe(200)
}
// The 4th signup from that IP is refused — the cap is 3.
const capped = await postToken('grant_type=create_account&platform_id=steam-cap3', ip)
expect(capped.status).toBe(400)
expect(capped.json.error).toBe('invalid_grant')
expect(capped.json.error_description).toMatch(/network/)
// A different IP is unaffected, and the capped IP can still LOG IN to what it has.
const other = await postToken('grant_type=create_account&platform_id=steam-cap4', '198.51.100.23')
expect(other.status).toBe(200)
})
test('POST /connect/token does not cap logins, only signups', async () => {
// Account 42's owner may be over the signup cap; that must never lock them out of
// an account they already have.
const res = await postToken(
`grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`,
'198.51.100.22'
)
expect(res.status).toBe(200)
})
test('POST /connect/token create_account seeds the new player into Orientation', async () => {
const payload = await tokenFor('grant_type=create_account&platform_id=steam-456')
const sub = payload.sub as string
+10 -2
View File
@@ -169,7 +169,11 @@ async function syncMemberCount(db: D1Database, clubId: number): Promise<number>
.first<{ n: number }>()
const count = row?.n ?? 0
await db
.prepare("UPDATE club SET data = json_set(data, '$.MemberCount', ?2) WHERE club_id = ?1")
// CAST to INTEGER: D1 binds a JS number as a SQLite REAL, which json_set would write
// into the blob as `"MemberCount":3.0` — and this blob is served to the client.
.prepare(
"UPDATE club SET data = json_set(data, '$.MemberCount', CAST(?2 AS INTEGER)) WHERE club_id = ?1"
)
.bind(clubId, count)
.run()
return count
@@ -506,7 +510,11 @@ export async function setHomeClub(
clubId: number
): Promise<void> {
await db
.prepare("UPDATE account SET data = json_set(data, '$.homeClubId', ?2) WHERE account_id = ?1")
// CAST to INTEGER — see syncMemberCount: a bound JS number lands as a REAL, so this
// would otherwise store `"homeClubId":7.0`.
.prepare(
"UPDATE account SET data = json_set(data, '$.homeClubId', CAST(?2 AS INTEGER)) WHERE account_id = ?1"
)
.bind(accountId, clubId)
.run()
}
+76 -11
View File
@@ -52,12 +52,21 @@ export interface Account {
/**
* The client's `device_id` from its most recent login (a stable per-install hash
* the client sends on every /connect/token). Not a credential — the client picks
* it and nothing verifies it — so never authorize on it alone. Kept (and indexed)
* so accounts sharing a device can be found later, e.g. for account linkup.
* it and nothing verifies it — so never authorize on it alone. Kept so accounts
* sharing a device can be found later, e.g. for account linkup.
*/
deviceId?: string
/** DeviceClass int (2 = PC/standalone) that `deviceId` was last seen on. */
deviceClass?: number
/**
* The client IP the account was CREATED from (Cloudflare's CF-Connecting-IP).
* Immutable once set — it's what a "how many accounts came from this IP" signup
* cap counts, so refreshing it on login would let an abuser hop IPs to reset
* their own count. Empty when the header is absent (e.g. in tests).
*/
signupIp?: string
/** The client IP of the most recent successful login; refreshed on every login. */
lastLoginIp?: string
/** Set via POST /account/me/email; absent until the player provides one. */
email?: string
/** Set via POST /account/me/phone; absent until the player provides one. */
@@ -243,25 +252,81 @@ export async function setLastLoginTime(db: D1Database, id: number, time: string)
}
/**
* Record the device the account most recently logged in from. Called on every
* successful login (not just account creation) so the stored device tracks the
* player as they move between devices.
* Record where the account most recently logged in from — its device and client IP.
* Called on every successful login (not just account creation) so both track the
* player as they move between devices and networks. Each field is written only when
* present, so a login that reports no device id doesn't blank the stored one.
*
* `signupIp` is deliberately NOT touched here: it records the account's origin and
* must stay immutable for signup caps to mean anything.
*/
export async function setDeviceInfo(
export async function setLoginContext(
db: D1Database,
id: number,
deviceId: string,
deviceClass: number
ctx: { deviceId?: string; deviceClass?: number; ip?: string }
): Promise<void> {
if (deviceId === '') return
const sets: string[] = []
const binds: Array<string | number> = []
if (ctx.deviceId) {
sets.push(`'$.deviceId', ?${binds.length + 2}`)
binds.push(ctx.deviceId)
if (ctx.deviceClass !== undefined) {
// CAST to INTEGER: D1 binds a JS number as a SQLite REAL, and json_set would then
// write `"deviceClass":2.0` into the blob rather than `2`.
sets.push(`'$.deviceClass', CAST(?${binds.length + 2} AS INTEGER)`)
binds.push(ctx.deviceClass)
}
}
if (ctx.ip) {
sets.push(`'$.lastLoginIp', ?${binds.length + 2}`)
binds.push(ctx.ip)
}
if (sets.length === 0) return
await db
.prepare(
"UPDATE account SET data = json_set(data, '$.deviceId', ?2, '$.deviceClass', ?3) WHERE account_id = ?1"
`UPDATE account SET data = json_set(data, ${sets.join(', ')}) WHERE account_id = ?1`
)
.bind(id, deviceId, deviceClass)
.bind(id, ...binds)
.run()
}
/**
* How many accounts were created from a given client IP — the count a signup cap
* ("no more than N accounts per IP") is enforced against. Counts `signupIp`, which
* never changes after creation, NOT `lastLoginIp`.
*
* An empty ip counts 0: when Cloudflare gives us no client IP we can't attribute the
* signup to anyone, and a cap that lumped every unattributed account together would
* lock out real players.
*
* NB: an IP is a coarse identity. Households, NAT, and shared campus/mobile networks
* put many legitimate players behind one address, so any cap here should be generous
* and pair with the (much sharper) per-platform-id cap.
*/
export async function countAccountsBySignupIp(db: D1Database, ip: string): Promise<number> {
if (ip === '') return 0
const row = await db
.prepare("SELECT COUNT(*) AS n FROM account WHERE json_extract(data, '$.signupIp') = ?1")
.bind(ip)
.first<{ n: number }>()
return row?.n ?? 0
}
/**
* How many accounts are linked to a platform-native id (e.g. one SteamID64) — the
* count a per-platform signup cap is enforced against. Backed by the indexed
* `platform_id` generated column. An empty id counts 0 (accounts with no platform
* identity aren't attributable to a platform user).
*/
export async function countAccountsByPlatformId(db: D1Database, platformId: string): Promise<number> {
if (platformId === '') return 0
const row = await db
.prepare('SELECT COUNT(*) AS n FROM account WHERE platform_id = ?1')
.bind(platformId)
.first<{ n: number }>()
return row?.n ?? 0
}
/** Look up multiple accounts by AccountId (order not guaranteed). */
export async function getAccountsByIds(db: D1Database, ids: number[]): Promise<Account[]> {
if (ids.length === 0) return []