diff --git a/.env.example b/.env.example index 9e229f9..9bd74cc 100644 --- a/.env.example +++ b/.env.example @@ -70,6 +70,13 @@ RECFLARE_DOMAIN=rec.example.com # RECFLARE_MAX_ACCOUNTS_PER_IP=3 # RECFLARE_MAX_ACCOUNTS_PER_DEVICE_ID=3 +# Whether accounts can be created from the WEBSITE (`auth` and `www`) — a password +# account with no Steam/Meta identity, which nothing but the caps above can limit, and a +# VPN or Tor exit resets the IP one. `on` or `off`; unset is OFF. Signing in to an +# existing password account is never affected, nor is creating one by launching the game. +# Turning it on also needs the Turnstile keypair in the Secrets Store (see www). +# RECFLARE_PASSWORD_SIGNUP=off + # 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 # BOTH, so a ban also blocks accounts sharing a proven platform identity or an IP with a diff --git a/apps/auth/src/auth.app.ts b/apps/auth/src/auth.app.ts index 2b13552..6c17616 100644 --- a/apps/auth/src/auth.app.ts +++ b/apps/auth/src/auth.app.ts @@ -25,6 +25,7 @@ import { writeAuditLog, } from '@repo/domain' import { + flagVar, intVar, logger, withCleanSpec, @@ -144,6 +145,20 @@ const DEFAULT_MAX_ACCOUNTS_PER_PLATFORM_ID = 3 const DEFAULT_MAX_ACCOUNTS_PER_IP = 3 const DEFAULT_MAX_ACCOUNTS_PER_DEVICE_ID = 3 +/** + * Whether a `create_account` with no platform — a password account, which is what the + * website mints — is accepted at all (`PASSWORD_SIGNUP`; see context.ts). Off by default: + * such an account has no identity but the address it came from, so the caps above are + * the only thing limiting it, and a VPN or Tor exit resets the IP arm every N accounts. + * That is how one player made 150 accounts in a weekend. A game-client signup always + * carries a Steam or Meta identity and is untouched by this switch. + * + * Refused as `invalid_grant` with this description, which `www` translates for the form + * (see its auth-messages.ts) — keep the two in step. + */ +const DEFAULT_PASSWORD_SIGNUP = false +const PASSWORD_SIGNUP_DISABLED_DESCRIPTION = 'password signup is disabled' + /** New players start in the Orientation room (RoomId 13) — the new-user flow. */ const ORIENTATION_ROOM_ID = 13 /** @@ -617,7 +632,10 @@ const app = new Hono() '', '**`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', - '`password` becomes the login credential. Subject to three independent signup caps,', + '`password` becomes the login credential. Without a `platform` this is a PASSWORD', + 'account (what the website makes), accepted only while `PASSWORD_SIGNUP` is on —', + 'off by default, refused as `invalid_grant` "password signup is disabled". Subject', + 'to three independent signup caps,', 'per verified platform id, per signup IP and per client `device_id`', '(`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', @@ -762,7 +780,7 @@ const app = new Hono() // - cached_login authenticates purely by platform identity. // - create_account that asserts a platform: we won't bind an identity we can't // prove. (create_account with NO platform is the password-account path — - // allowed, but binds no platformId.) + // binds no platformId, and is open only while PASSWORD_SIGNUP says so.) // // A password grant is NOT gated: the password already proved who it is. It posts // its platform proof too, and if that verifies we LINK the identity to the account @@ -851,6 +869,17 @@ const app = new Hono() // via create_account or /account/me/changepassword. let accountId: string if (grantType === 'create_account') { + // The password-account path (no platform) is a switch, off by default: see + // DEFAULT_PASSWORD_SIGNUP. Checked first, before anything is read or minted — + // a closed door costs no D1 round trip and burns no cap slot. + if (!platformAsserted && !flagVar(c.env.PASSWORD_SIGNUP, DEFAULT_PASSWORD_SIGNUP)) { + logger.info('signup refused: password signup is disabled', { ip: clientIp }) + return c.json( + { error: 'invalid_grant', error_description: PASSWORD_SIGNUP_DISABLED_DESCRIPTION }, + 400 + ) + } + // A banned player's next move is a new account, so the evasion arms are checked // BEFORE one is minted — against the only identity a signup has, the IP it came // from and the platform identity it just proved. Refusing after the fact (as the diff --git a/apps/auth/src/context.ts b/apps/auth/src/context.ts index 6698e6c..d3b9adf 100644 --- a/apps/auth/src/context.ts +++ b/apps/auth/src/context.ts @@ -27,6 +27,16 @@ export type Env = SharedHonoEnv & { MAX_ACCOUNTS_PER_PLATFORM_ID?: string | number MAX_ACCOUNTS_PER_IP?: string | number MAX_ACCOUNTS_PER_DEVICE_ID?: string | number + /** + * Whether `create_account` accepts a signup with NO platform identity — the password + * account the website makes (`www` `/api/signup`), or anything else that posts the + * grant without a `platform`. `on`/`off` (or true/false, 1/0); unset means OFF, so a + * server opens web signup deliberately. Read through `flagVar`. The `www` worker reads + * the same knob to hide its form and refuse its endpoint, so the two agree for free. + * Platform signups (Steam, Meta) are never governed by this — they always have an + * identity to authenticate with later, and the caps above are what limit them. + */ + PASSWORD_SIGNUP?: string | number | boolean /** * 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 diff --git a/apps/auth/src/test/integration/api.test.ts b/apps/auth/src/test/integration/api.test.ts index dc6fb33..729ed00 100644 --- a/apps/auth/src/test/integration/api.test.ts +++ b/apps/auth/src/test/integration/api.test.ts @@ -1076,6 +1076,59 @@ describe('auth worker routes', () => { expect(login.status).toBe(200) }) + test('PASSWORD_SIGNUP=off refuses a no-platform create_account and mints nothing', async () => { + // The switch the website's signup hangs off. Every other create_account test here + // runs with it ON (see vitest.config.ts); this one is the closed door. + const original = env.PASSWORD_SIGNUP + try { + env.PASSWORD_SIGNUP = 'off' + const before = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ + n: number + }>() + const refused = await postToken( + 'grant_type=create_account&password=hunter2&device_id=dev-web&device_class=2', + '198.51.100.60' + ) + expect(refused.status).toBe(400) + expect(refused.json.error).toBe('invalid_grant') + expect(refused.json.error_description).toBe('password signup is disabled') + const after = await env.DB.prepare('SELECT COUNT(*) AS n FROM account').first<{ + n: number + }>() + expect(after?.n).toBe(before?.n) + + // Logging in is not what the switch gates: an existing password account still gets + // its token, whatever the switch says. + const login = await postToken( + `grant_type=password&username=Player42&password=${LOGIN_PASSWORD}`, + '198.51.100.60' + ) + expect(login.status).toBe(200) + + // A platform signup is a different door: it still reaches platform verification + // (and fails THERE, on the missing ticket) rather than being refused by the switch. + const steam = await postToken( + 'grant_type=create_account&platform=0&platform_id=76561198000000001', + '198.51.100.60' + ) + expect(steam.status).toBe(400) + expect(steam.json.error_description).not.toBe('password signup is disabled') + + // Unset is off too — the default an operator has to opt out of. + delete env.PASSWORD_SIGNUP + const unset = await postToken('grant_type=create_account&password=hunter2', '198.51.100.61') + expect(unset.status).toBe(400) + expect(unset.json.error_description).toBe('password signup is disabled') + + // And `on` reopens it. + env.PASSWORD_SIGNUP = 'on' + const open = await postToken('grant_type=create_account&password=hunter2', '198.51.100.62') + expect(open.status).toBe(200) + } finally { + env.PASSWORD_SIGNUP = original + } + }) + 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 // auth.app.ts is only the fallback. `env` is shared by every test in this file, so the diff --git a/apps/auth/vitest.config.ts b/apps/auth/vitest.config.ts index de0d903..7afdae6 100644 --- a/apps/auth/vitest.config.ts +++ b/apps/auth/vitest.config.ts @@ -8,6 +8,10 @@ export default defineConfig({ miniflare: { bindings: { ENVIRONMENT: 'VITEST', + // Password (no-platform) signup is OFF by default, and most create_account tests + // post no `platform` — they'd all test the closed door. The switch itself is + // covered by a test that flips it off and back. + PASSWORD_SIGNUP: 'on', }, }, }), diff --git a/apps/www/src/auth-messages.ts b/apps/www/src/auth-messages.ts index e1c6071..ddd755d 100644 --- a/apps/www/src/auth-messages.ts +++ b/apps/www/src/auth-messages.ts @@ -19,7 +19,16 @@ export type AuthAction = 'signup' | 'login' * with no platform, sign-in posts `password` — but they're mapped anyway so a future web * flow that does assert one can't regress to a bare code. */ +/** + * Web signup switched off (`PASSWORD_SIGNUP`, enforced by auth). Exported because www's + * own `/api/signup` answers with it before ever calling auth, and the two must read the + * same on the form. + */ +export const PASSWORD_SIGNUP_DISABLED = + 'Account creation from the website is disabled. Launch the game to create an account.' + const AUTH_MESSAGES: Record = { + 'password signup is disabled': PASSWORD_SIGNUP_DISABLED, 'too many accounts created from this network': 'Too many accounts have already been created from your network. Try again later, or from a different connection.', 'account limit reached for this platform account': diff --git a/apps/www/src/client/App.tsx b/apps/www/src/client/App.tsx index dc90d52..33a8e30 100644 --- a/apps/www/src/client/App.tsx +++ b/apps/www/src/client/App.tsx @@ -2024,6 +2024,14 @@ function LoginPage({ ) : ( <>

Sign in

+ {/* Someone who arrived at /signup while it's closed gets told so, instead of a + sign-in form appearing where they expected to create an account. Only once + the config has landed: before that, closed is just the not-yet-known state. */} + {tab === 'signup' && config && !config.signupEnabled && ( +

+ Account creation from the website is disabled. Launch the game to create an account. +

+ )}

Use your username and password. Launching the game also creates an account, linked to your Steam ID — set a password on it and it signs in here too. diff --git a/apps/www/src/context.ts b/apps/www/src/context.ts index 52bed25..b214337 100644 --- a/apps/www/src/context.ts +++ b/apps/www/src/context.ts @@ -66,6 +66,15 @@ export type Env = SharedHonoEnv & { * MAX_TOKEN_GIFT. Unset means DEFAULT_MAX_XP_GIFT (100). */ MAX_XP_GIFT?: string | number + /** + * Whether web signup is open — the same `PASSWORD_SIGNUP` knob `auth` reads to accept + * or refuse a no-platform `create_account`, delivered to both workers by the deploy + * scripts. `on`/`off`; unset means OFF. Read here so `/api/config` can report signup + * closed (the SPA hides the form) and `/api/signup` can refuse with the player-facing + * sentence, instead of every visitor discovering it from auth's refusal. It is a + * courtesy, not the enforcement: auth refuses regardless of what www says. + */ + PASSWORD_SIGNUP?: string | number | boolean /** * Service binding to the `auth` worker — how the BFF reaches it, so the browser's real * IP survives the hop (see wrangler.jsonc and src/upstream.ts `postAuthForm`). diff --git a/apps/www/src/test/integration/api.test.ts b/apps/www/src/test/integration/api.test.ts index 1b10c45..f8121b0 100644 --- a/apps/www/src/test/integration/api.test.ts +++ b/apps/www/src/test/integration/api.test.ts @@ -235,6 +235,60 @@ it('treats an unresolvable or half-configured keypair as signup being off', asyn }) }) +// The operator's switch, off by default. `auth` is what enforces it (see its tests); www +// reads the same knob so the SPA hides the form and the endpoint answers in the sentence +// the form would show, rather than every visitor learning it from auth's refusal. +it('PASSWORD_SIGNUP=off reports signup closed and refuses the endpoint', async () => { + const original = env.PASSWORD_SIGNUP + try { + env.PASSWORD_SIGNUP = 'off' + const config = (await (await SELF.fetch('https://example.com/api/config')).json()) as { + signupEnabled: boolean + turnstileSiteKey: string | null + } + // Closed, and the site key withheld with it: no widget to mount for a form that + // isn't offered. + expect(config.signupEnabled).toBe(false) + expect(config.turnstileSiteKey).toBeNull() + + const res = await SELF.fetch('https://example.com/api/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'hunter2', turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX' }), + }) + expect(res.status).toBe(403) + expect(await res.json()).toEqual({ + error: 'Account creation from the website is disabled. Launch the game to create an account.', + }) + + // Unset is off as well. + delete env.PASSWORD_SIGNUP + const unset = await SELF.fetch('https://example.com/api/signup', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ password: 'hunter2', turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX' }), + }) + expect(unset.status).toBe(403) + } finally { + env.PASSWORD_SIGNUP = original + } +}) + +// auth's own refusal of the grant, should a request reach it anyway, reads the same. +it('translates auth’s password-signup refusal into the same sentence', async () => { + const failure = await readAuthError( + new Response( + JSON.stringify({ error: 'invalid_grant', error_description: 'password signup is disabled' }), + { status: 400, headers: { 'content-type': 'application/json' } } + ), + 'signup' + ) + expect(failure.message).toBe( + 'Account creation from the website is disabled. Launch the game to create an account.' + ) + expect(failure.status).toBe(400) +}) + it('refuses a signup with no Turnstile token', async () => { const res = await SELF.fetch('https://example.com/api/signup', { method: 'POST', diff --git a/apps/www/src/turnstile.ts b/apps/www/src/turnstile.ts index 7f5c880..8792c92 100644 --- a/apps/www/src/turnstile.ts +++ b/apps/www/src/turnstile.ts @@ -1,4 +1,4 @@ -import { logger } from '@repo/hono-helpers' +import { flagVar, logger } from '@repo/hono-helpers' import type { Env } from './context' @@ -44,6 +44,14 @@ const SITEVERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverif * pair belongs to no account and passes without a human. Deliberately not a built-in * fallback: the same code path then runs everywhere. */ +/** + * Whether the operator has switched web (password) signup on — `PASSWORD_SIGNUP`, the + * knob `auth` enforces on its `create_account` grant, read here for the form and the + * endpoint's early refusal. Unset is OFF: a web account has no identity but its address, + * so a server opens this deliberately. See `Env.PASSWORD_SIGNUP`. + */ +export const passwordSignupOpen = (env: Env): boolean => flagVar(env.PASSWORD_SIGNUP, false) + export async function turnstileKeys( env: Env ): Promise<{ siteKey: string; secretKey: string } | null> { diff --git a/apps/www/src/www.app.ts b/apps/www/src/www.app.ts index 1dc7217..b7f07c6 100644 --- a/apps/www/src/www.app.ts +++ b/apps/www/src/www.app.ts @@ -25,7 +25,7 @@ import { // it the box one cron run would, so the amount and the box are econ's and not a copy. import { DEFAULT_STARTING_TOKENS } from '../../econ/src/balance-db' import { grantDiscordRoleGift } from '../../econ/src/discord-role-gift' -import { authUnreachable } from './auth-messages' +import { authUnreachable, PASSWORD_SIGNUP_DISABLED } from './auth-messages' import { AUTHORIZE_URL, discordConfig, @@ -58,7 +58,7 @@ import { searchReportsHandler, topReportedHandler, } from './staff' -import { turnstileKeys, verifyTurnstile } from './turnstile' +import { passwordSignupOpen, turnstileKeys, verifyTurnstile } from './turnstile' import { accountsBase, apiBase, @@ -166,11 +166,16 @@ const app = new Hono() // it calls directly. All three are served rather than baked into the client build so // one build works for any operator. The site key is public (it ships in the widget // markup either way); the secret never leaves the worker. + // + // Signup is open only when BOTH hold: the operator has switched password signup on + // (`PASSWORD_SIGNUP`, the knob auth enforces) and a Turnstile keypair is configured to + // guard it. Either missing and the SPA offers no form. .get('/api/config', async (c) => { const [keys, discord] = await Promise.all([turnstileKeys(c.env), discordConfig(c.env)]) + const signupEnabled = passwordSignupOpen(c.env) && keys !== null return c.json({ - signupEnabled: keys !== null, - turnstileSiteKey: keys?.siteKey ?? null, + signupEnabled, + turnstileSiteKey: signupEnabled ? keys.siteKey : null, // The benefits claim, on the same terms: open only when it's fully configured, and // the SPA is handed a ready-made consent URL rather than the parts to build one. // Nothing secret is served — the client id inside it is public — and the guild/role @@ -250,11 +255,16 @@ const app = new Hono() // ---- Signup ------------------------------------------------------------- - // Create an account from the website, behind a Turnstile bot check. The check is what - // makes this safe to leave open: `auth` binds no platform identity to a web account, so - // its per-IP cap (3, never decaying) is the only other thing in front of this path — - // and `auth` has no bot check of its own, which is why this one endpoint can't simply - // be called from the browser like the rest. + // Create an account from the website, behind a Turnstile bot check. `auth` binds no + // platform identity to a web account, so its signup caps are the only other thing in + // front of this path — and `auth` has no bot check of its own, which is why this one + // endpoint can't simply be called from the browser like the rest. + // + // The whole path is behind the `PASSWORD_SIGNUP` switch, OFF by default: a web account + // has no identity but its address, and the per-IP cap is reset by every VPN or Tor exit + // (one player made 150 accounts that way in a weekend). `auth` is what enforces the + // switch — it refuses the grant whatever this worker does — so the check here only + // spares a visitor the Turnstile round trip and answers in the sentence the form shows. // // Deliberately passes NO `platform`: create_account treats an asserted platform as one // to verify against Steam and would reject RecNet, so this is the platform-less @@ -265,6 +275,7 @@ const app = new Hono() // account's email, when the player gave one, is saved by the client afterwards with // that token — `create_account` takes no email, and `accounts` owns the field. .post('/api/signup', async (c) => { + if (!passwordSignupOpen(c.env)) return c.json({ error: PASSWORD_SIGNUP_DISABLED }, 403) // No usable keypair means signup is closed rather than unprotected (see turnstile.ts). const keys = await turnstileKeys(c.env) if (!keys) return c.json({ error: 'Account creation is currently disabled.' }, 403) diff --git a/apps/www/vitest.config.ts b/apps/www/vitest.config.ts index 3a8607c..9e883b0 100644 --- a/apps/www/vitest.config.ts +++ b/apps/www/vitest.config.ts @@ -24,6 +24,9 @@ export default defineConfig({ }, bindings: { ENVIRONMENT: 'VITEST', + // Web signup is OFF unless the operator switches it on; the signup tests below + // exercise the open door, and one flips this off to pin the closed one. + PASSWORD_SIGNUP: 'on', // The Turnstile keypair is NOT bound here: both keys come from the Secrets // Store now, and the tests seed the local store with the test pair (see // src/test/integration/api.test.ts). A plain binding of the same name would diff --git a/packages/hono-helpers/src/helpers/env.ts b/packages/hono-helpers/src/helpers/env.ts index 4552db8..c8a08da 100644 --- a/packages/hono-helpers/src/helpers/env.ts +++ b/packages/hono-helpers/src/helpers/env.ts @@ -17,3 +17,33 @@ export function intVar(value: unknown, fallback: number): number { const parsed = Number(value) return Number.isInteger(parsed) ? parsed : fallback } + +/** + * Read an on/off worker var, falling back to `fallback` when it is unset or unusable. + * + * Accepts the spellings an operator plausibly types — `on`/`off`, `true`/`false`, + * `yes`/`no`, `1`/`0`, in any case — plus a real boolean or number, since a var declared + * in wrangler.jsonc `vars` keeps its JSON type while one set with `--var` is a string (see + * `intVar`). Anything else (an empty string, `enabled`, a typo) is treated as unset rather + * than read as true or false: a switch that guards something sensitive must not flip on a + * misspelling, so the documented default is the safe failure. + */ +export function flagVar(value: unknown, fallback: boolean): boolean { + if (typeof value === 'boolean') return value + if (typeof value === 'number') return value === 1 ? true : value === 0 ? false : fallback + if (typeof value !== 'string') return fallback + switch (value.trim().toLowerCase()) { + case 'on': + case 'true': + case 'yes': + case '1': + return true + case 'off': + case 'false': + case 'no': + case '0': + return false + default: + return fallback + } +}