mirror of
https://github.com/djdevin/recflare.git
synced 2026-10-25 23:18:51 -07:00
[www] flag to disable websignups
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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<App>()
|
||||
'',
|
||||
'**`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<App>()
|
||||
// - 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<App>()
|
||||
// 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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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<string, string> = {
|
||||
'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':
|
||||
|
||||
@@ -2024,6 +2024,14 @@ function LoginPage({
|
||||
) : (
|
||||
<>
|
||||
<h2>Sign in</h2>
|
||||
{/* 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 && (
|
||||
<p className="warn">
|
||||
Account creation from the website is disabled. Launch the game to create an account.
|
||||
</p>
|
||||
)}
|
||||
<p className="muted">
|
||||
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.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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> {
|
||||
|
||||
+20
-9
@@ -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<App>()
|
||||
// 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<App>()
|
||||
|
||||
// ---- 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<App>()
|
||||
// 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user