feat: add PRIVATE_MODE site-wide auth gate (0.48.0)
When `PRIVATE_MODE=true`, every API path except a small allowlist
(`/health`, `/auth/{config,login,logout,register}`) requires a valid
session cookie or bearer token — anonymous reads are rejected with
401. Self-registration is force-disabled in private mode regardless
of `ALLOW_SELF_REGISTER`, so a locked-down instance flips with a
single switch (admins still mint accounts via `POST /admin/users`).
The backend gate is a tower middleware that reuses the existing
`CurrentUser` extractor, so the cookie + bearer paths cannot drift
from per-handler auth. `/auth/config` now exposes the flag plus the
effective `self_register_enabled` value so the frontend can render
the navbar correctly on the first paint.
On the frontend, a new universal root `+layout.ts` fetches the
config and redirects anonymous visitors to `/login?next=<path>`
before page-specific loads fire. The redirect is UX only — the
backend middleware is the source of truth, so crafted requests
still 401.
Defaults stay public (`PRIVATE_MODE=false`); existing deployments
need no env change.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1470,7 +1470,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.47.0"
|
||||
version = "0.48.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.47.0"
|
||||
version = "0.48.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
@@ -42,18 +42,22 @@ 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".
|
||||
/// Public, unauthenticated. Exposes anonymous-relevant auth policy so
|
||||
/// the frontend can render its login / register affordances correctly
|
||||
/// without a probe request that would conflate "disabled" with
|
||||
/// "rate-limited". `self_register_enabled` is the *effective* value
|
||||
/// (`allow_self_register && !private_mode`), so a private-mode
|
||||
/// instance reports `false` even if the raw flag is on.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct AuthConfigResponse {
|
||||
pub self_register_enabled: bool,
|
||||
pub private_mode: bool,
|
||||
}
|
||||
|
||||
async fn auth_config(State(state): State<AppState>) -> Json<AuthConfigResponse> {
|
||||
Json(AuthConfigResponse {
|
||||
self_register_enabled: state.auth.allow_self_register,
|
||||
self_register_enabled: state.auth.allow_self_register && !state.auth.private_mode,
|
||||
private_mode: state.auth.private_mode,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -103,7 +107,10 @@ async fn register(
|
||||
// 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 {
|
||||
// Private mode force-blocks self-registration regardless of
|
||||
// ALLOW_SELF_REGISTER — operators of locked-down instances mint
|
||||
// accounts via `POST /admin/users` instead.
|
||||
if !state.auth.allow_self_register || state.auth.private_mode {
|
||||
return Err(AppError::Forbidden);
|
||||
}
|
||||
let username = input.username.trim();
|
||||
|
||||
@@ -3,8 +3,10 @@ use std::sync::atomic::AtomicBool;
|
||||
|
||||
use anyhow::Context;
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::DefaultBodyLimit;
|
||||
use axum::extract::{DefaultBodyLimit, FromRequestParts, Request, State};
|
||||
use axum::http::{HeaderName, HeaderValue, Method};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::Response;
|
||||
use axum::Router;
|
||||
use sqlx::postgres::PgPoolOptions;
|
||||
use sqlx::PgPool;
|
||||
@@ -12,7 +14,9 @@ use tokio_util::sync::CancellationToken;
|
||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||
use tower_http::trace::TraceLayer;
|
||||
|
||||
use crate::auth::extractor::CurrentUser;
|
||||
use crate::auth::rate_limit::AuthRateLimiter;
|
||||
use crate::error::AppError;
|
||||
use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig};
|
||||
use crate::crawler::browser_manager::{self, BrowserManager};
|
||||
use crate::crawler::content::{self, SyncOutcome};
|
||||
@@ -353,11 +357,62 @@ pub fn router(state: AppState) -> Router {
|
||||
let max_request_bytes = state.upload.max_request_bytes;
|
||||
Router::new()
|
||||
.nest("/api/v1", crate::api::routes())
|
||||
.layer(middleware::from_fn_with_state(
|
||||
state.clone(),
|
||||
private_mode_guard,
|
||||
))
|
||||
.layer(DefaultBodyLimit::max(max_request_bytes))
|
||||
.with_state(state)
|
||||
.layer(TraceLayer::new_for_http())
|
||||
}
|
||||
|
||||
/// Paths reachable anonymously even when `PRIVATE_MODE=true`. Login and
|
||||
/// logout are needed for the auth flow itself; `/health` is reserved
|
||||
/// for load-balancer probes; `/auth/config` lets the frontend decide
|
||||
/// whether to render the login form or its anonymous alternatives;
|
||||
/// `/auth/register` is exempted from the gate so the handler can
|
||||
/// return its informative `registration_disabled` 403 (the same code
|
||||
/// public-mode deployments use when `ALLOW_SELF_REGISTER=false`) —
|
||||
/// the handler itself force-blocks the request body in private mode,
|
||||
/// so no account ever gets created here. Everything else demands a
|
||||
/// valid session cookie or bearer token.
|
||||
fn is_public_in_private_mode(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/api/v1/health"
|
||||
| "/api/v1/auth/config"
|
||||
| "/api/v1/auth/login"
|
||||
| "/api/v1/auth/logout"
|
||||
| "/api/v1/auth/register"
|
||||
)
|
||||
}
|
||||
|
||||
/// Site-wide auth gate for `PRIVATE_MODE=true`. With the flag off this
|
||||
/// is a no-op pass-through, so public deployments take no extra DB
|
||||
/// hit. With it on, the guard reuses [`CurrentUser`] — the same
|
||||
/// session-cookie-then-bearer-token logic the per-handler extractor
|
||||
/// uses — so the two paths can never drift.
|
||||
async fn private_mode_guard(
|
||||
State(state): State<AppState>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
if !state.auth.private_mode {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
if is_public_in_private_mode(req.uri().path()) {
|
||||
return Ok(next.run(req).await);
|
||||
}
|
||||
let (mut parts, body) = req.into_parts();
|
||||
match CurrentUser::from_request_parts(&mut parts, &state).await {
|
||||
Ok(_) => {
|
||||
let req = Request::from_parts(parts, body);
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
Err(_) => Err(AppError::Unauthenticated),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
|
||||
if allowed_origins.is_empty() {
|
||||
// Same-origin only — no CORS headers emitted.
|
||||
|
||||
@@ -19,6 +19,14 @@ pub struct AuthConfig {
|
||||
/// `POST /admin/users`. Defaults to `true` (open registration)
|
||||
/// for backward compatibility.
|
||||
pub allow_self_register: bool,
|
||||
/// When `true`, every API path except a small allowlist
|
||||
/// (`/health`, `/auth/config`, `/auth/login`, `/auth/logout`)
|
||||
/// requires a valid session cookie or bearer token — anonymous
|
||||
/// reads are rejected with 401. Self-registration is also
|
||||
/// force-disabled regardless of [`Self::allow_self_register`]
|
||||
/// so a private instance is locked down with a single switch.
|
||||
/// Defaults to `false` (current public behaviour).
|
||||
pub private_mode: bool,
|
||||
}
|
||||
|
||||
impl Default for AuthConfig {
|
||||
@@ -33,6 +41,7 @@ impl Default for AuthConfig {
|
||||
// defaults.
|
||||
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
||||
allow_self_register: true,
|
||||
private_mode: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -181,6 +190,7 @@ impl Config {
|
||||
) as u32,
|
||||
},
|
||||
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
||||
private_mode: env_bool("PRIVATE_MODE", false),
|
||||
},
|
||||
upload: UploadConfig {
|
||||
max_request_bytes: env_usize("MAX_REQUEST_BYTES", 200 * 1024 * 1024),
|
||||
@@ -373,5 +383,37 @@ mod tests {
|
||||
let cfg = CrawlerConfig::from_env().expect("from_env");
|
||||
assert_eq!(cfg.manga_limit, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_mode_env_parses_true() {
|
||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||
std::env::set_var("PRIVATE_MODE", "true");
|
||||
std::env::set_var("DATABASE_URL", "postgres://test");
|
||||
let cfg = Config::from_env().expect("from_env");
|
||||
std::env::remove_var("PRIVATE_MODE");
|
||||
std::env::remove_var("DATABASE_URL");
|
||||
assert!(cfg.auth.private_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_mode_env_parses_false() {
|
||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||
std::env::set_var("PRIVATE_MODE", "false");
|
||||
std::env::set_var("DATABASE_URL", "postgres://test");
|
||||
let cfg = Config::from_env().expect("from_env");
|
||||
std::env::remove_var("PRIVATE_MODE");
|
||||
std::env::remove_var("DATABASE_URL");
|
||||
assert!(!cfg.auth.private_mode);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn private_mode_defaults_to_false() {
|
||||
let _g = ENV_GUARD.lock().unwrap_or_else(|p| p.into_inner());
|
||||
std::env::remove_var("PRIVATE_MODE");
|
||||
std::env::set_var("DATABASE_URL", "postgres://test");
|
||||
let cfg = Config::from_env().expect("from_env");
|
||||
std::env::remove_var("DATABASE_URL");
|
||||
assert!(!cfg.auth.private_mode);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
189
backend/tests/api_private_mode.rs
Normal file
189
backend/tests/api_private_mode.rs
Normal file
@@ -0,0 +1,189 @@
|
||||
//! Site-wide auth gate (`PRIVATE_MODE=true`).
|
||||
//!
|
||||
//! With private mode on, every API path except a small allowlist
|
||||
//! (`/health`, `/auth/config`, `/auth/login`, `/auth/logout`) requires
|
||||
//! a valid session cookie or bearer token, and `/auth/register` is
|
||||
//! force-blocked regardless of `ALLOW_SELF_REGISTER`. With private mode
|
||||
//! off (the default), nothing changes — the `public_mode_*` test
|
||||
//! pins that regression guard.
|
||||
|
||||
mod common;
|
||||
|
||||
use serde_json::json;
|
||||
use sqlx::PgPool;
|
||||
use tower::ServiceExt;
|
||||
|
||||
use axum::http::StatusCode;
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_blocks_anonymous_manga_list(pool: PgPool) {
|
||||
let h = common::harness_with_private_mode(pool);
|
||||
let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_blocks_anonymous_files(pool: PgPool) {
|
||||
let h = common::harness_with_private_mode(pool);
|
||||
// The path doesn't have to exist — the guard runs before routing,
|
||||
// so the response is 401 (not 404). That's the property the test
|
||||
// is pinning: nothing leaks via crafted URLs.
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/files/anything.png"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_allows_session_cookie_read(pool: PgPool) {
|
||||
// Register through a non-private harness sharing the same DB pool
|
||||
// so the session row exists. Then exercise the gate using a fresh
|
||||
// private-mode harness against the same DB.
|
||||
let public = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&public.app).await;
|
||||
|
||||
let private = common::harness_with_private_mode(pool);
|
||||
let resp = private
|
||||
.app
|
||||
.oneshot(common::get_with_cookie("/api/v1/mangas", &cookie))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_allows_bearer_token_read(pool: PgPool) {
|
||||
let public = common::harness(pool.clone());
|
||||
let (_, cookie) = common::register_user(&public.app).await;
|
||||
|
||||
let resp = public
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json_with_cookie(
|
||||
"/api/v1/auth/tokens",
|
||||
json!({ "name": "private-mode-bot" }),
|
||||
&cookie,
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::CREATED);
|
||||
let body = common::body_json(resp).await;
|
||||
let bearer = body["bearer"].as_str().unwrap().to_string();
|
||||
|
||||
let private = common::harness_with_private_mode(pool);
|
||||
let resp = private
|
||||
.app
|
||||
.oneshot(common::get_with_bearer("/api/v1/mangas", &bearer))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_allows_login_endpoint_anonymous(pool: PgPool) {
|
||||
// Seed a user via the public harness so login has credentials to
|
||||
// verify against.
|
||||
let public = common::harness(pool.clone());
|
||||
let _ = public
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/register",
|
||||
json!({ "username": "alice", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
let private = common::harness_with_private_mode(pool);
|
||||
let resp = private
|
||||
.app
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/login",
|
||||
json!({ "username": "alice", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
// Reaches the login handler and succeeds — *not* 401 from the
|
||||
// gate. That's the property we're pinning.
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_allows_health_and_config_anonymous(pool: PgPool) {
|
||||
let h = common::harness_with_private_mode(pool);
|
||||
|
||||
let r = h
|
||||
.app
|
||||
.clone()
|
||||
.oneshot(common::get("/api/v1/health"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::OK);
|
||||
|
||||
let r = h
|
||||
.app
|
||||
.oneshot(common::get("/api/v1/auth/config"))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn private_mode_blocks_register_even_when_self_register_enabled(pool: PgPool) {
|
||||
// harness_with_private_mode keeps `allow_self_register=true` (the
|
||||
// default) — private mode is supposed to force-block register
|
||||
// regardless. That's what this test pins.
|
||||
let h = common::harness_with_private_mode(pool);
|
||||
let resp = h
|
||||
.app
|
||||
.oneshot(common::post_json(
|
||||
"/api/v1/auth/register",
|
||||
json!({ "username": "alice", "password": "hunter2hunter2" }),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
|
||||
let body = common::body_json(resp).await;
|
||||
assert_eq!(body["error"]["code"], "forbidden");
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn auth_config_reports_private_mode_and_effective_self_register(pool: PgPool) {
|
||||
let h = common::harness_with_private_mode(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["private_mode"], true);
|
||||
// Effective value: `allow_self_register && !private_mode` is false
|
||||
// here even though the raw `allow_self_register` is true.
|
||||
assert_eq!(body["self_register_enabled"], false);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn public_mode_does_not_gate_anonymous_reads(pool: PgPool) {
|
||||
// Regression guard: with private_mode off (the default), the gate
|
||||
// must be a no-op so existing public deployments stay public.
|
||||
let h = common::harness(pool);
|
||||
let resp = h.app.oneshot(common::get("/api/v1/mangas")).await.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
}
|
||||
|
||||
#[sqlx::test(migrations = "./migrations")]
|
||||
async fn public_mode_reports_private_mode_false(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["private_mode"], false);
|
||||
assert_eq!(body["self_register_enabled"], true);
|
||||
}
|
||||
@@ -92,6 +92,21 @@ pub fn harness_with_self_register_disabled(pool: PgPool) -> Harness {
|
||||
harness_with_auth_config(pool, storage, storage_dir, auth)
|
||||
}
|
||||
|
||||
/// Like [`harness`] but flips `PRIVATE_MODE` on so the site-wide auth
|
||||
/// gate is exercised. `allow_self_register` stays at its default `true`
|
||||
/// to verify that private mode force-disables self-registration on top
|
||||
/// of whatever `ALLOW_SELF_REGISTER` says.
|
||||
pub fn harness_with_private_mode(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,
|
||||
private_mode: true,
|
||||
..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(
|
||||
|
||||
Reference in New Issue
Block a user