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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user