mirror of
https://github.com/djdevin/recflare.git
synced 2026-09-09 07:01:27 -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user