feat(auth): ALLOW_SELF_REGISTER toggle + public /auth/config endpoint (0.42.0)

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.
This commit is contained in:
MechaCat02
2026-05-31 13:56:18 +02:00
parent 6dd21451a8
commit 2f47faa11c
12 changed files with 182 additions and 5 deletions

2
backend/Cargo.lock generated
View File

@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
[[package]]
name = "mangalord"
version = "0.41.2"
version = "0.42.0"
dependencies = [
"anyhow",
"argon2",

View File

@@ -1,6 +1,6 @@
[package]
name = "mangalord"
version = "0.41.2"
version = "0.42.0"
edition = "2021"
default-run = "mangalord"

View File

@@ -28,6 +28,7 @@ use crate::repo;
pub fn routes() -> Router<AppState> {
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<AppState> {
.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<AppState>) -> Json<AuthConfigResponse> {
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<Credentials>,
) -> AppResult<impl IntoResponse> {
// 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)?;

View File

@@ -13,6 +13,12 @@ pub struct AuthConfig {
pub cookie_domain: Option<String>,
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),

View File

@@ -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");
}

View File

@@ -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(

View File

@@ -1,6 +1,6 @@
{
"name": "mangalord-frontend",
"version": "0.41.2",
"version": "0.42.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -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();

View File

@@ -100,3 +100,15 @@ export async function createToken(name: string): Promise<CreatedToken> {
export async function deleteToken(id: string): Promise<void> {
await request<void>(`/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<AuthConfig> {
return request<AuthConfig>('/v1/auth/config');
}

View File

@@ -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<void> {
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();

View File

@@ -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 @@
</button>
{:else}
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
{#if authConfig.self_register_enabled}
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
{/if}
{/if}
</div>
</header>

View File

@@ -1,6 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { register } from '$lib/api/auth';
import { authConfig } from '$lib/auth-config.svelte';
import { session } from '$lib/session.svelte';
let username = $state('');
@@ -8,6 +10,13 @@
let error: string | null = $state(null);
let submitting = $state(false);
// Direct navigation to /register bypasses the root layout's
// onMount — re-trigger the config load here so the disabled state
// renders correctly even when this is the first page hit.
onMount(() => {
if (!authConfig.loaded) authConfig.load();
});
async function submit(e: SubmitEvent) {
e.preventDefault();
error = null;
@@ -25,6 +34,12 @@
</script>
<h1>Register</h1>
{#if authConfig.loaded && !authConfig.self_register_enabled}
<p class="notice" role="status" data-testid="register-disabled">
Self-registration is disabled on this server. Ask an administrator to
create an account for you.
</p>
{:else}
<form onsubmit={submit} action="javascript:void(0)" data-testid="register-form">
<label class="form-field">
<span>Username</span>
@@ -56,6 +71,7 @@
<p class="form-error" role="alert" data-testid="register-error">{error}</p>
{/if}
</form>
{/if}
<p class="hint">
Already have an account? <a href="/login">Log in</a>.
</p>
@@ -90,4 +106,14 @@
color: var(--text-muted);
font-size: var(--font-sm);
}
.notice {
padding: var(--space-3);
border: 1px solid var(--border);
border-radius: var(--radius-md);
background: var(--surface-elevated);
color: var(--text);
max-width: 32rem;
margin: 0;
}
</style>