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:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.41.2"
|
version = "0.42.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.41.2"
|
version = "0.42.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ use crate::repo;
|
|||||||
|
|
||||||
pub fn routes() -> Router<AppState> {
|
pub fn routes() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/auth/config", get(auth_config))
|
||||||
.route("/auth/register", post(register))
|
.route("/auth/register", post(register))
|
||||||
.route("/auth/login", post(login))
|
.route("/auth/login", post(login))
|
||||||
.route("/auth/logout", post(logout))
|
.route("/auth/logout", post(logout))
|
||||||
@@ -41,6 +42,21 @@ pub fn routes() -> Router<AppState> {
|
|||||||
.route("/auth/tokens/:id", delete(delete_token))
|
.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)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct Credentials {
|
pub struct Credentials {
|
||||||
pub username: String,
|
pub username: String,
|
||||||
@@ -82,7 +98,14 @@ async fn register(
|
|||||||
jar: CookieJar,
|
jar: CookieJar,
|
||||||
Json(input): Json<Credentials>,
|
Json(input): Json<Credentials>,
|
||||||
) -> AppResult<impl IntoResponse> {
|
) -> 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")?;
|
check_auth_rate_limit(&state, "register")?;
|
||||||
|
if !state.auth.allow_self_register {
|
||||||
|
return Err(AppError::Forbidden);
|
||||||
|
}
|
||||||
let username = input.username.trim();
|
let username = input.username.trim();
|
||||||
validate_username(username)?;
|
validate_username(username)?;
|
||||||
validate_password(&input.password)?;
|
validate_password(&input.password)?;
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ pub struct AuthConfig {
|
|||||||
pub cookie_domain: Option<String>,
|
pub cookie_domain: Option<String>,
|
||||||
pub session_ttl_days: i64,
|
pub session_ttl_days: i64,
|
||||||
pub rate_limit: crate::auth::rate_limit::RateLimitConfig,
|
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 {
|
impl Default for AuthConfig {
|
||||||
@@ -26,6 +32,7 @@ impl Default for AuthConfig {
|
|||||||
// to the [`PRODUCTION_PER_SEC`]/[`PRODUCTION_BURST`]
|
// to the [`PRODUCTION_PER_SEC`]/[`PRODUCTION_BURST`]
|
||||||
// defaults.
|
// defaults.
|
||||||
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
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(),
|
crate::auth::rate_limit::PRODUCTION_BURST.into(),
|
||||||
) as u32,
|
) as u32,
|
||||||
},
|
},
|
||||||
|
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
||||||
},
|
},
|
||||||
upload: UploadConfig {
|
upload: UploadConfig {
|
||||||
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
||||||
|
|||||||
@@ -765,3 +765,44 @@ async fn create_token_rejects_name_over_64_chars(pool: PgPool) {
|
|||||||
assert_eq!(body["error"]["code"], "validation_failed");
|
assert_eq!(body["error"]["code"], "validation_failed");
|
||||||
assert!(body["error"]["details"]["name"].is_string());
|
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");
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,6 +78,20 @@ fn harness_with_auth_config(
|
|||||||
Harness { app: router(state), _storage_dir: storage_dir }
|
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
|
/// Like [`harness`] but configures a tight auth rate limit. Used by
|
||||||
/// the brute-force-rate-limiting test.
|
/// the brute-force-rate-limiting test.
|
||||||
pub fn harness_with_auth_rate_limit(
|
pub fn harness_with_auth_rate_limit(
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.41.2",
|
"version": "0.42.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -14,7 +14,8 @@ import {
|
|||||||
me,
|
me,
|
||||||
changePassword,
|
changePassword,
|
||||||
createToken,
|
createToken,
|
||||||
deleteToken
|
deleteToken,
|
||||||
|
getAuthConfig
|
||||||
} from './auth';
|
} from './auth';
|
||||||
|
|
||||||
function ok(body: unknown, status = 200): Response {
|
function ok(body: unknown, status = 200): Response {
|
||||||
@@ -169,6 +170,17 @@ describe('auth api client', () => {
|
|||||||
expect(url).toMatch(/\/v1\/auth\/tokens$/);
|
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 () => {
|
it('deleteToken DELETEs to /v1/auth/tokens/{id} and handles 204', async () => {
|
||||||
fetchSpy.mockResolvedValueOnce(noContent());
|
fetchSpy.mockResolvedValueOnce(noContent());
|
||||||
await expect(deleteToken('t1')).resolves.toBeUndefined();
|
await expect(deleteToken('t1')).resolves.toBeUndefined();
|
||||||
|
|||||||
@@ -100,3 +100,15 @@ export async function createToken(name: string): Promise<CreatedToken> {
|
|||||||
export async function deleteToken(id: string): Promise<void> {
|
export async function deleteToken(id: string): Promise<void> {
|
||||||
await request<void>(`/v1/auth/tokens/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
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');
|
||||||
|
}
|
||||||
|
|||||||
37
frontend/src/lib/auth-config.svelte.ts
Normal file
37
frontend/src/lib/auth-config.svelte.ts
Normal 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();
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
import { onMount, onDestroy } from 'svelte';
|
import { onMount, onDestroy } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { logout } from '$lib/api/auth';
|
import { logout } from '$lib/api/auth';
|
||||||
|
import { authConfig } from '$lib/auth-config.svelte';
|
||||||
import { preferences } from '$lib/preferences.svelte';
|
import { preferences } from '$lib/preferences.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
import { theme } from '$lib/theme.svelte';
|
import { theme } from '$lib/theme.svelte';
|
||||||
@@ -21,6 +22,7 @@
|
|||||||
theme.init();
|
theme.init();
|
||||||
preferences.init();
|
preferences.init();
|
||||||
if (!session.loaded) session.refresh();
|
if (!session.loaded) session.refresh();
|
||||||
|
if (!authConfig.loaded) authConfig.load();
|
||||||
|
|
||||||
// Publish the header's measured height as a CSS custom
|
// Publish the header's measured height as a CSS custom
|
||||||
// property so sticky descendants (e.g. the reader nav) can
|
// property so sticky descendants (e.g. the reader nav) can
|
||||||
@@ -115,8 +117,10 @@
|
|||||||
</button>
|
</button>
|
||||||
{:else}
|
{:else}
|
||||||
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
|
<a class="text-link" href="/login" data-testid="nav-login">Login</a>
|
||||||
|
{#if authConfig.self_register_enabled}
|
||||||
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
|
<a class="text-link" href="/register" data-testid="nav-register">Register</a>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { register } from '$lib/api/auth';
|
import { register } from '$lib/api/auth';
|
||||||
|
import { authConfig } from '$lib/auth-config.svelte';
|
||||||
import { session } from '$lib/session.svelte';
|
import { session } from '$lib/session.svelte';
|
||||||
|
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
@@ -8,6 +10,13 @@
|
|||||||
let error: string | null = $state(null);
|
let error: string | null = $state(null);
|
||||||
let submitting = $state(false);
|
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) {
|
async function submit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
error = null;
|
error = null;
|
||||||
@@ -25,6 +34,12 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<h1>Register</h1>
|
<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">
|
<form onsubmit={submit} action="javascript:void(0)" data-testid="register-form">
|
||||||
<label class="form-field">
|
<label class="form-field">
|
||||||
<span>Username</span>
|
<span>Username</span>
|
||||||
@@ -56,6 +71,7 @@
|
|||||||
<p class="form-error" role="alert" data-testid="register-error">{error}</p>
|
<p class="form-error" role="alert" data-testid="register-error">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</form>
|
</form>
|
||||||
|
{/if}
|
||||||
<p class="hint">
|
<p class="hint">
|
||||||
Already have an account? <a href="/login">Log in</a>.
|
Already have an account? <a href="/login">Log in</a>.
|
||||||
</p>
|
</p>
|
||||||
@@ -90,4 +106,14 @@
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-size: var(--font-sm);
|
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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user