mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-08 14:41:28 -07:00
add an account limit
This commit is contained in:
@@ -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,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
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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 []
|
||||
|
||||
Reference in New Issue
Block a user