From 9d0d00992fdb3a5e38e0ee8f872a308248a8c5da Mon Sep 17 00:00:00 2001 From: Devin Zuczek Date: Tue, 14 Jul 2026 11:49:47 -0400 Subject: [PATCH] add an account limit --- apps/auth/src/auth.app.ts | 64 +++++++++++++++- apps/auth/src/test/integration/api.test.ts | 81 +++++++++++++++++--- apps/clubs/src/clubs-db.ts | 12 ++- packages/domain/src/accounts-db.ts | 87 +++++++++++++++++++--- 4 files changed, 216 insertions(+), 28 deletions(-) diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 7c934fe..d354d41 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -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 = { 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() 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() // 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() 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() } 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() } 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( diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index 7e3c8fa..a9e09fb 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -69,24 +69,29 @@ function decodePayload(token: string): Record { ) as Record } -async function accessTokenFor(body: string): Promise { - 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 { + return (await postToken(body, ip)).json.access_token as string } -async function tokenFor(body: string): Promise> { - return decodePayload(await accessTokenFor(body)) +async function tokenFor(body: string, ip?: string): Promise> { + 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 }> { +/** + * 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 }> { 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 } @@ -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 diff --git a/apps/clubs/src/clubs-db.ts b/apps/clubs/src/clubs-db.ts index 05bb886..6ca5f78 100644 --- a/apps/clubs/src/clubs-db.ts +++ b/apps/clubs/src/clubs-db.ts @@ -169,7 +169,11 @@ async function syncMemberCount(db: D1Database, clubId: number): Promise .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 { 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() } diff --git a/packages/domain/src/accounts-db.ts b/packages/domain/src/accounts-db.ts index 71be201..7575910 100644 --- a/packages/domain/src/accounts-db.ts +++ b/packages/domain/src/accounts-db.ts @@ -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 { - if (deviceId === '') return + const sets: string[] = [] + const binds: Array = [] + 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 { + 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 { + 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 { if (ids.length === 0) return []