From 2f47faa11c75ccb5ab604d026759d8d2410e0f05 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Sun, 31 May 2026 13:56:18 +0200 Subject: [PATCH] feat(auth): ALLOW_SELF_REGISTER toggle + public /auth/config endpoint (0.42.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets operators run a closed-membership deployment by setting ALLOW_SELF_REGISTER=false (default true, so existing deploys are unaffected). When off, POST /auth/register returns 403 forbidden. The rate-limit token is consumed BEFORE the disabled check so the timing doesn't distinguish enabled-but-rejected from disabled — closes the toggle-state probe channel. New public GET /auth/config returns { self_register_enabled: bool } so the frontend can render its register affordances correctly without conflating "disabled" with "rate-limited" (which a probe attempt would). Frontend: a lightweight reactive `authConfig` store loads the flag once on root-layout mount (and again on /register direct navigation, which bypasses the layout's onMount). Header hides the Register link when the toggle is off; /register renders a "self-registration is disabled — ask an administrator" notice instead of the form. Admin-create endpoint that pairs with this toggle is intentionally not in this PR — it lands as the next branch (feat/admin-user-create). The toggle alone is independently useful for deployments that want to lock down enrollment without yet wiring an admin UI. --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- backend/src/api/auth.rs | 23 +++++++++++++ backend/src/config.rs | 8 +++++ backend/tests/api_auth.rs | 41 +++++++++++++++++++++++ backend/tests/common/mod.rs | 14 ++++++++ frontend/package.json | 2 +- frontend/src/lib/api/auth.test.ts | 14 +++++++- frontend/src/lib/api/auth.ts | 12 +++++++ frontend/src/lib/auth-config.svelte.ts | 37 ++++++++++++++++++++ frontend/src/routes/+layout.svelte | 6 +++- frontend/src/routes/register/+page.svelte | 26 ++++++++++++++ 12 files changed, 182 insertions(+), 5 deletions(-) create mode 100644 frontend/src/lib/auth-config.svelte.ts diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 181a3ce..6f8be4b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.41.2" +version = "0.42.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 87942a5..7cb4c1b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.41.2" +version = "0.42.0" edition = "2021" default-run = "mangalord" diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index 2309923..4264213 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -28,6 +28,7 @@ use crate::repo; pub fn routes() -> Router { Router::new() + .route("/auth/config", get(auth_config)) .route("/auth/register", post(register)) .route("/auth/login", post(login)) .route("/auth/logout", post(logout)) @@ -41,6 +42,21 @@ pub fn routes() -> Router { .route("/auth/tokens/:id", delete(delete_token)) } +/// Public, unauthenticated. Exposes anonymous-relevant auth policy +/// (currently just whether self-registration is open) so the frontend +/// can render its login / register affordances correctly without a +/// probe request that would conflate "disabled" with "rate-limited". +#[derive(Debug, Serialize)] +pub struct AuthConfigResponse { + pub self_register_enabled: bool, +} + +async fn auth_config(State(state): State) -> Json { + Json(AuthConfigResponse { + self_register_enabled: state.auth.allow_self_register, + }) +} + #[derive(Debug, Deserialize)] pub struct Credentials { pub username: String, @@ -82,7 +98,14 @@ async fn register( jar: CookieJar, Json(input): Json, ) -> AppResult { + // Rate limit before the disabled check so an operator who flips + // the toggle can't be probed for the toggle state via timing — + // disabled and enabled paths both consume a token, and disabled + // returns 403 instead of running argon2. check_auth_rate_limit(&state, "register")?; + if !state.auth.allow_self_register { + return Err(AppError::Forbidden); + } let username = input.username.trim(); validate_username(username)?; validate_password(&input.password)?; diff --git a/backend/src/config.rs b/backend/src/config.rs index 5851cb7..183145f 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -13,6 +13,12 @@ pub struct AuthConfig { pub cookie_domain: Option, pub session_ttl_days: i64, pub rate_limit: crate::auth::rate_limit::RateLimitConfig, + /// When `false`, `POST /auth/register` returns 403 + /// `registration_disabled` and the frontend hides its register + /// affordance. Admins can still mint accounts via + /// `POST /admin/users`. Defaults to `true` (open registration) + /// for backward compatibility. + pub allow_self_register: bool, } impl Default for AuthConfig { @@ -26,6 +32,7 @@ impl Default for AuthConfig { // to the [`PRODUCTION_PER_SEC`]/[`PRODUCTION_BURST`] // defaults. rate_limit: crate::auth::rate_limit::RateLimitConfig::default(), + allow_self_register: true, } } } @@ -150,6 +157,7 @@ impl Config { crate::auth::rate_limit::PRODUCTION_BURST.into(), ) as u32, }, + allow_self_register: env_bool("ALLOW_SELF_REGISTER", true), }, upload: UploadConfig { max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024), diff --git a/backend/tests/api_auth.rs b/backend/tests/api_auth.rs index 8a5c8d7..e572b3b 100644 --- a/backend/tests/api_auth.rs +++ b/backend/tests/api_auth.rs @@ -765,3 +765,44 @@ async fn create_token_rejects_name_over_64_chars(pool: PgPool) { assert_eq!(body["error"]["code"], "validation_failed"); assert!(body["error"]["details"]["name"].is_string()); } + +// ---- self-register toggle + /auth/config ----------------------------------- + +#[sqlx::test(migrations = "./migrations")] +async fn auth_config_reports_self_register_enabled_by_default(pool: PgPool) { + let h = common::harness(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/auth/config")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["self_register_enabled"], true); +} + +#[sqlx::test(migrations = "./migrations")] +async fn auth_config_reflects_self_register_disabled(pool: PgPool) { + let h = common::harness_with_self_register_disabled(pool); + let resp = h + .app + .oneshot(common::get("/api/v1/auth/config")) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let body = common::body_json(resp).await; + assert_eq!(body["self_register_enabled"], false); +} + +#[sqlx::test(migrations = "./migrations")] +async fn register_returns_403_when_self_register_disabled(pool: PgPool) { + let h = common::harness_with_self_register_disabled(pool); + let resp = h + .app + .oneshot(common::post_json("/api/v1/auth/register", creds("alice"))) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::FORBIDDEN); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "forbidden"); +} diff --git a/backend/tests/common/mod.rs b/backend/tests/common/mod.rs index dc0de86..1fceb89 100644 --- a/backend/tests/common/mod.rs +++ b/backend/tests/common/mod.rs @@ -78,6 +78,20 @@ fn harness_with_auth_config( Harness { app: router(state), _storage_dir: storage_dir } } +/// Like [`harness`] but flips `ALLOW_SELF_REGISTER` off so the +/// register-disabled test exercises the 403 branch in +/// `api::auth::register`. +pub fn harness_with_self_register_disabled(pool: PgPool) -> Harness { + let storage_dir = tempfile::tempdir().expect("tempdir"); + let storage = Arc::new(LocalStorage::new(storage_dir.path())); + let auth = AuthConfig { + cookie_secure: false, + allow_self_register: false, + ..AuthConfig::default() + }; + harness_with_auth_config(pool, storage, storage_dir, auth) +} + /// Like [`harness`] but configures a tight auth rate limit. Used by /// the brute-force-rate-limiting test. pub fn harness_with_auth_rate_limit( diff --git a/frontend/package.json b/frontend/package.json index 58b8575..66575cf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.41.2", + "version": "0.42.0", "private": true, "type": "module", "scripts": { diff --git a/frontend/src/lib/api/auth.test.ts b/frontend/src/lib/api/auth.test.ts index 5281f93..3a40766 100644 --- a/frontend/src/lib/api/auth.test.ts +++ b/frontend/src/lib/api/auth.test.ts @@ -14,7 +14,8 @@ import { me, changePassword, createToken, - deleteToken + deleteToken, + getAuthConfig } from './auth'; function ok(body: unknown, status = 200): Response { @@ -169,6 +170,17 @@ describe('auth api client', () => { expect(url).toMatch(/\/v1\/auth\/tokens$/); }); + it('getAuthConfig GETs /v1/auth/config and parses the flag', async () => { + fetchSpy.mockResolvedValueOnce(ok({ self_register_enabled: false })); + const cfg = await getAuthConfig(); + expect(cfg.self_register_enabled).toBe(false); + const url = fetchSpy.mock.calls[0][0] as string; + expect(url).toMatch(/\/v1\/auth\/config$/); + const init = fetchSpy.mock.calls[0][1] as RequestInit; + // Public endpoint; no method override means default GET. + expect(init?.method ?? 'GET').toBe('GET'); + }); + it('deleteToken DELETEs to /v1/auth/tokens/{id} and handles 204', async () => { fetchSpy.mockResolvedValueOnce(noContent()); await expect(deleteToken('t1')).resolves.toBeUndefined(); diff --git a/frontend/src/lib/api/auth.ts b/frontend/src/lib/api/auth.ts index b57956e..e27ccad 100644 --- a/frontend/src/lib/api/auth.ts +++ b/frontend/src/lib/api/auth.ts @@ -100,3 +100,15 @@ export async function createToken(name: string): Promise { export async function deleteToken(id: string): Promise { await request(`/v1/auth/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' }); } + +export type AuthConfig = { + /** When false, /v1/auth/register returns 403 and the UI should + * hide its register affordance. Admins can still mint accounts + * via POST /v1/admin/users. */ + self_register_enabled: boolean; +}; + +/** Public — no auth, no cookie required. */ +export async function getAuthConfig(): Promise { + return request('/v1/auth/config'); +} diff --git a/frontend/src/lib/auth-config.svelte.ts b/frontend/src/lib/auth-config.svelte.ts new file mode 100644 index 0000000..8bfa377 --- /dev/null +++ b/frontend/src/lib/auth-config.svelte.ts @@ -0,0 +1,37 @@ +// Anonymous-relevant auth policy (currently just whether self- +// registration is enabled). Loaded once per browser session on root- +// layout mount, then read reactively from `authConfig.self_register_enabled`. +// +// Defaults to `self_register_enabled = true` while loading so the +// register link doesn't flash off-and-on for the default-open case. +// If the fetch fails (network blip, backend restart), the stale value +// is kept — there's no per-request retry. A new tab will retry on its +// own mount. +// +// Same browser-only contract as `session.svelte.ts` — see that file's +// SSR comment. + +import { browser } from '$app/environment'; +import { getAuthConfig } from './api/auth'; + +class AuthConfigStore { + self_register_enabled = $state(true); + loaded = $state(false); + private loading = false; + + async load(): Promise { + if (this.loaded || this.loading || !browser) return; + this.loading = true; + try { + const cfg = await getAuthConfig(); + this.self_register_enabled = cfg.self_register_enabled; + this.loaded = true; + } catch { + // Keep optimistic default; next page mount will retry. + } finally { + this.loading = false; + } + } +} + +export const authConfig = new AuthConfigStore(); diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 97e0627..270d2f2 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { goto } from '$app/navigation'; import { logout } from '$lib/api/auth'; + import { authConfig } from '$lib/auth-config.svelte'; import { preferences } from '$lib/preferences.svelte'; import { session } from '$lib/session.svelte'; import { theme } from '$lib/theme.svelte'; @@ -21,6 +22,7 @@ theme.init(); preferences.init(); if (!session.loaded) session.refresh(); + if (!authConfig.loaded) authConfig.load(); // Publish the header's measured height as a CSS custom // property so sticky descendants (e.g. the reader nav) can @@ -115,7 +117,9 @@ {:else} Login - Register + {#if authConfig.self_register_enabled} + Register + {/if} {/if} diff --git a/frontend/src/routes/register/+page.svelte b/frontend/src/routes/register/+page.svelte index 626609f..5d49473 100644 --- a/frontend/src/routes/register/+page.svelte +++ b/frontend/src/routes/register/+page.svelte @@ -1,6 +1,8 @@

Register

+{#if authConfig.loaded && !authConfig.self_register_enabled} +

+ Self-registration is disabled on this server. Ask an administrator to + create an account for you. +

+{:else}