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]]
|
[[package]]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.47.0"
|
version = "0.48.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"argon2",
|
"argon2",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "mangalord"
|
name = "mangalord"
|
||||||
version = "0.47.0"
|
version = "0.48.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
default-run = "mangalord"
|
default-run = "mangalord"
|
||||||
|
|
||||||
|
|||||||
@@ -42,18 +42,22 @@ 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
|
/// Public, unauthenticated. Exposes anonymous-relevant auth policy so
|
||||||
/// (currently just whether self-registration is open) so the frontend
|
/// the frontend can render its login / register affordances correctly
|
||||||
/// can render its login / register affordances correctly without a
|
/// without a probe request that would conflate "disabled" with
|
||||||
/// probe request that would conflate "disabled" with "rate-limited".
|
/// "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)]
|
#[derive(Debug, Serialize)]
|
||||||
pub struct AuthConfigResponse {
|
pub struct AuthConfigResponse {
|
||||||
pub self_register_enabled: bool,
|
pub self_register_enabled: bool,
|
||||||
|
pub private_mode: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn auth_config(State(state): State<AppState>) -> Json<AuthConfigResponse> {
|
async fn auth_config(State(state): State<AppState>) -> Json<AuthConfigResponse> {
|
||||||
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
|
// disabled and enabled paths both consume a token, and disabled
|
||||||
// returns 403 instead of running argon2.
|
// returns 403 instead of running argon2.
|
||||||
check_auth_rate_limit(&state, "register")?;
|
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);
|
return Err(AppError::Forbidden);
|
||||||
}
|
}
|
||||||
let username = input.username.trim();
|
let username = input.username.trim();
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ use std::sync::atomic::AtomicBool;
|
|||||||
|
|
||||||
use anyhow::Context;
|
use anyhow::Context;
|
||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use axum::extract::DefaultBodyLimit;
|
use axum::extract::{DefaultBodyLimit, FromRequestParts, Request, State};
|
||||||
use axum::http::{HeaderName, HeaderValue, Method};
|
use axum::http::{HeaderName, HeaderValue, Method};
|
||||||
|
use axum::middleware::{self, Next};
|
||||||
|
use axum::response::Response;
|
||||||
use axum::Router;
|
use axum::Router;
|
||||||
use sqlx::postgres::PgPoolOptions;
|
use sqlx::postgres::PgPoolOptions;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
@@ -12,7 +14,9 @@ use tokio_util::sync::CancellationToken;
|
|||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
use tower_http::trace::TraceLayer;
|
use tower_http::trace::TraceLayer;
|
||||||
|
|
||||||
|
use crate::auth::extractor::CurrentUser;
|
||||||
use crate::auth::rate_limit::AuthRateLimiter;
|
use crate::auth::rate_limit::AuthRateLimiter;
|
||||||
|
use crate::error::AppError;
|
||||||
use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig};
|
use crate::config::{AuthConfig, Config, CrawlerConfig, UploadConfig};
|
||||||
use crate::crawler::browser_manager::{self, BrowserManager};
|
use crate::crawler::browser_manager::{self, BrowserManager};
|
||||||
use crate::crawler::content::{self, SyncOutcome};
|
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;
|
let max_request_bytes = state.upload.max_request_bytes;
|
||||||
Router::new()
|
Router::new()
|
||||||
.nest("/api/v1", crate::api::routes())
|
.nest("/api/v1", crate::api::routes())
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
state.clone(),
|
||||||
|
private_mode_guard,
|
||||||
|
))
|
||||||
.layer(DefaultBodyLimit::max(max_request_bytes))
|
.layer(DefaultBodyLimit::max(max_request_bytes))
|
||||||
.with_state(state)
|
.with_state(state)
|
||||||
.layer(TraceLayer::new_for_http())
|
.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 {
|
pub(crate) fn cors_layer(allowed_origins: &[String]) -> CorsLayer {
|
||||||
if allowed_origins.is_empty() {
|
if allowed_origins.is_empty() {
|
||||||
// Same-origin only — no CORS headers emitted.
|
// Same-origin only — no CORS headers emitted.
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ pub struct AuthConfig {
|
|||||||
/// `POST /admin/users`. Defaults to `true` (open registration)
|
/// `POST /admin/users`. Defaults to `true` (open registration)
|
||||||
/// for backward compatibility.
|
/// for backward compatibility.
|
||||||
pub allow_self_register: bool,
|
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 {
|
impl Default for AuthConfig {
|
||||||
@@ -33,6 +41,7 @@ impl Default for AuthConfig {
|
|||||||
// defaults.
|
// defaults.
|
||||||
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
rate_limit: crate::auth::rate_limit::RateLimitConfig::default(),
|
||||||
allow_self_register: true,
|
allow_self_register: true,
|
||||||
|
private_mode: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -181,6 +190,7 @@ impl Config {
|
|||||||
) as u32,
|
) as u32,
|
||||||
},
|
},
|
||||||
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
allow_self_register: env_bool("ALLOW_SELF_REGISTER", true),
|
||||||
|
private_mode: env_bool("PRIVATE_MODE", false),
|
||||||
},
|
},
|
||||||
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),
|
||||||
@@ -373,5 +383,37 @@ mod tests {
|
|||||||
let cfg = CrawlerConfig::from_env().expect("from_env");
|
let cfg = CrawlerConfig::from_env().expect("from_env");
|
||||||
assert_eq!(cfg.manga_limit, 0);
|
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)
|
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
|
/// 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(
|
||||||
|
|||||||
101
frontend/e2e/private-mode.spec.ts
Normal file
101
frontend/e2e/private-mode.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { test, expect, type Page } from '@playwright/test';
|
||||||
|
|
||||||
|
// Network-level mocks for the private-mode UX. The backend integration
|
||||||
|
// tests (api_private_mode.rs) cover the actual gate; here we only
|
||||||
|
// verify that the SvelteKit universal load redirects anonymous
|
||||||
|
// visitors to /login and then back to where they were going.
|
||||||
|
|
||||||
|
const userFixture = {
|
||||||
|
id: 'user-1',
|
||||||
|
username: 'alice',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
is_admin: false
|
||||||
|
};
|
||||||
|
const emptyPage = { items: [], page: { limit: 50, offset: 0, total: null } };
|
||||||
|
|
||||||
|
async function stubPrivateInstance(page: Page) {
|
||||||
|
let loggedIn = false;
|
||||||
|
|
||||||
|
// The flag that flips the gate on. Frontend reads it in
|
||||||
|
// `+layout.ts` to decide whether to redirect.
|
||||||
|
await page.route('**/api/v1/auth/config', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
self_register_enabled: false,
|
||||||
|
private_mode: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/auth/me', async (route) => {
|
||||||
|
if (loggedIn) {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: userFixture })
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 401,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({
|
||||||
|
error: { code: 'unauthenticated', message: 'unauthenticated' }
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.route('**/api/v1/auth/login', async (route) => {
|
||||||
|
loggedIn = true;
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify({ user: userFixture })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// The real backend would 401 these too in private mode; we stub
|
||||||
|
// success so the post-login navigation can render the home page
|
||||||
|
// without an additional redirect cycle.
|
||||||
|
await page.route('**/api/v1/mangas*', async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: 'application/json',
|
||||||
|
body: JSON.stringify(emptyPage)
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('private mode: anonymous visit to / redirects to /login?next=%2F', async ({ page }) => {
|
||||||
|
await stubPrivateInstance(page);
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
||||||
|
await expect(page.getByTestId('login-username')).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private mode: register link is hidden', async ({ page }) => {
|
||||||
|
await stubPrivateInstance(page);
|
||||||
|
await page.goto('/login');
|
||||||
|
await expect(page.getByTestId('nav-login')).toBeVisible();
|
||||||
|
// self_register_enabled is the effective value (false in private
|
||||||
|
// mode regardless of ALLOW_SELF_REGISTER), so the navbar must
|
||||||
|
// never render the register affordance here.
|
||||||
|
await expect(page.getByTestId('nav-register')).toHaveCount(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('private mode: after login the user lands back on the requested page', async ({ page }) => {
|
||||||
|
await stubPrivateInstance(page);
|
||||||
|
|
||||||
|
// Visit a deep link → bounced to /login with next= preserving it.
|
||||||
|
await page.goto('/');
|
||||||
|
await expect(page).toHaveURL(/\/login\?next=%2F$/);
|
||||||
|
|
||||||
|
await page.getByTestId('login-username').fill('alice');
|
||||||
|
await page.getByTestId('login-password').fill('hunter2hunter2');
|
||||||
|
await page.getByTestId('login-submit').click();
|
||||||
|
|
||||||
|
// Authenticated → can now reach the home page without bouncing.
|
||||||
|
await expect(page.getByTestId('session-user')).toContainText('alice');
|
||||||
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "mangalord-frontend",
|
"name": "mangalord-frontend",
|
||||||
"version": "0.47.0",
|
"version": "0.48.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
@@ -102,10 +102,14 @@ export async function deleteToken(id: string): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AuthConfig = {
|
export type AuthConfig = {
|
||||||
/** When false, /v1/auth/register returns 403 and the UI should
|
/** Effective value (`allow_self_register && !private_mode`).
|
||||||
|
* When false, /v1/auth/register returns 403 and the UI should
|
||||||
* hide its register affordance. Admins can still mint accounts
|
* hide its register affordance. Admins can still mint accounts
|
||||||
* via POST /v1/admin/users. */
|
* via POST /v1/admin/users. */
|
||||||
self_register_enabled: boolean;
|
self_register_enabled: boolean;
|
||||||
|
/** When true, every read endpoint requires auth and anonymous
|
||||||
|
* visitors are redirected to `/login` (see `+layout.ts`). */
|
||||||
|
private_mode: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Public — no auth, no cookie required. */
|
/** Public — no auth, no cookie required. */
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { getAuthConfig } from './api/auth';
|
|||||||
|
|
||||||
class AuthConfigStore {
|
class AuthConfigStore {
|
||||||
self_register_enabled = $state(true);
|
self_register_enabled = $state(true);
|
||||||
|
private_mode = $state(false);
|
||||||
loaded = $state(false);
|
loaded = $state(false);
|
||||||
private loading = false;
|
private loading = false;
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ class AuthConfigStore {
|
|||||||
try {
|
try {
|
||||||
const cfg = await getAuthConfig();
|
const cfg = await getAuthConfig();
|
||||||
this.self_register_enabled = cfg.self_register_enabled;
|
this.self_register_enabled = cfg.self_register_enabled;
|
||||||
|
this.private_mode = cfg.private_mode;
|
||||||
this.loaded = true;
|
this.loaded = true;
|
||||||
} catch {
|
} catch {
|
||||||
// Keep optimistic default; next page mount will retry.
|
// Keep optimistic default; next page mount will retry.
|
||||||
@@ -32,6 +34,16 @@ class AuthConfigStore {
|
|||||||
this.loading = false;
|
this.loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Seed from server-rendered layout data so the very first paint
|
||||||
|
* doesn't flash the loading state. Used by `+layout.ts` /
|
||||||
|
* `+layout.svelte` on the universal-load path. Safe to call from
|
||||||
|
* SSR (no `browser` guard) since it touches only reactive state. */
|
||||||
|
seed(cfg: { self_register_enabled: boolean; private_mode: boolean }): void {
|
||||||
|
this.self_register_enabled = cfg.self_register_enabled;
|
||||||
|
this.private_mode = cfg.private_mode;
|
||||||
|
this.loaded = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authConfig = new AuthConfigStore();
|
export const authConfig = new AuthConfigStore();
|
||||||
|
|||||||
@@ -14,15 +14,23 @@
|
|||||||
import Shield from '@lucide/svelte/icons/shield';
|
import Shield from '@lucide/svelte/icons/shield';
|
||||||
import '$lib/styles/tokens.css';
|
import '$lib/styles/tokens.css';
|
||||||
|
|
||||||
let { children } = $props();
|
let { children, data } = $props();
|
||||||
let loggingOut = $state(false);
|
let loggingOut = $state(false);
|
||||||
let headerEl: HTMLElement | undefined = $state();
|
let headerEl: HTMLElement | undefined = $state();
|
||||||
|
|
||||||
|
// Seed authConfig from the universal layout load. $effect keeps
|
||||||
|
// the store in sync if `data` is replaced by a subsequent layout
|
||||||
|
// load (client-side nav). The first run also covers initial
|
||||||
|
// hydration so the navbar's register link reflects the real
|
||||||
|
// server flag without a separate fetch.
|
||||||
|
$effect(() => {
|
||||||
|
authConfig.seed(data.authConfig);
|
||||||
|
});
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
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
|
||||||
|
|||||||
41
frontend/src/routes/+layout.ts
Normal file
41
frontend/src/routes/+layout.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Universal root load. Surfaces /auth/config to every page so the
|
||||||
|
// navbar + layout can render without an extra round-trip, and — when
|
||||||
|
// the backend reports PRIVATE_MODE=true — bounces anonymous visitors
|
||||||
|
// to /login before any page-specific load fires. The backend
|
||||||
|
// middleware is still the source of truth for the gate; this just
|
||||||
|
// matches the UX so users don't see a page full of failed fetches.
|
||||||
|
import type { LayoutLoad } from './$types';
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth';
|
||||||
|
|
||||||
|
// Paths reachable anonymously even when private_mode is on. /login is
|
||||||
|
// the entry point of the auth flow; everything else (including
|
||||||
|
// /register, which is force-blocked in private mode) bounces.
|
||||||
|
const PRIVATE_MODE_BYPASS = new Set(['/login']);
|
||||||
|
|
||||||
|
const PUBLIC_DEFAULTS: AuthConfig = {
|
||||||
|
self_register_enabled: true,
|
||||||
|
private_mode: false
|
||||||
|
};
|
||||||
|
|
||||||
|
export const load: LayoutLoad = async ({ url }) => {
|
||||||
|
let authConfig: AuthConfig = PUBLIC_DEFAULTS;
|
||||||
|
try {
|
||||||
|
authConfig = await getAuthConfig();
|
||||||
|
} catch {
|
||||||
|
// Fail-soft: keep the optimistic public-mode defaults so a
|
||||||
|
// backend hiccup doesn't lock anyone out of the login page.
|
||||||
|
// No private data can leak through here — the backend
|
||||||
|
// middleware is still authoritative for the gate.
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authConfig.private_mode && !PRIVATE_MODE_BYPASS.has(url.pathname)) {
|
||||||
|
const user = await me().catch(() => null);
|
||||||
|
if (!user) {
|
||||||
|
const next = url.pathname + url.search;
|
||||||
|
redirect(302, `/login?next=${encodeURIComponent(next)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { authConfig };
|
||||||
|
};
|
||||||
113
frontend/src/routes/layout.test.ts
Normal file
113
frontend/src/routes/layout.test.ts
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
|
// Mock the API client *before* importing the load function so the
|
||||||
|
// module under test picks up the mock when it resolves its imports.
|
||||||
|
vi.mock('$lib/api/auth', () => ({
|
||||||
|
getAuthConfig: vi.fn(),
|
||||||
|
me: vi.fn()
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { load } from './+layout';
|
||||||
|
import { getAuthConfig, me, type AuthConfig } from '$lib/api/auth';
|
||||||
|
|
||||||
|
type MinimalLoadEvent = { url: { pathname: string; search: string } };
|
||||||
|
|
||||||
|
function event(pathname: string, search = ''): MinimalLoadEvent {
|
||||||
|
return { url: { pathname, search } };
|
||||||
|
}
|
||||||
|
|
||||||
|
// `LayoutLoad`'s declared return type is `void | …`. Our `load`
|
||||||
|
// always returns `{ authConfig }`, but TypeScript can't narrow on
|
||||||
|
// that at the call site. Wrap to remove the `void` arm so the
|
||||||
|
// assertions stay terse.
|
||||||
|
async function callLoad(ev: MinimalLoadEvent): Promise<{ authConfig: AuthConfig }> {
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = await load(ev as any);
|
||||||
|
return result as { authConfig: AuthConfig };
|
||||||
|
}
|
||||||
|
|
||||||
|
const PUBLIC_CFG = { self_register_enabled: true, private_mode: false };
|
||||||
|
const PRIVATE_CFG = { self_register_enabled: false, private_mode: true };
|
||||||
|
|
||||||
|
const aliceUser = {
|
||||||
|
id: 'u1',
|
||||||
|
username: 'alice',
|
||||||
|
created_at: '2026-01-01T00:00:00Z',
|
||||||
|
is_admin: false
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('root +layout load', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getAuthConfig).mockReset();
|
||||||
|
vi.mocked(me).mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('public mode: returns authConfig data, never calls me()', async () => {
|
||||||
|
vi.mocked(getAuthConfig).mockResolvedValue(PUBLIC_CFG);
|
||||||
|
const data = await callLoad(event('/'));
|
||||||
|
expect(data.authConfig).toEqual(PUBLIC_CFG);
|
||||||
|
expect(me).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private mode + anonymous on `/`: throws redirect(302) to /login with next=', async () => {
|
||||||
|
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||||
|
vi.mocked(me).mockResolvedValue(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await expect(load(event('/') as any)).rejects.toMatchObject({
|
||||||
|
status: 302,
|
||||||
|
location: '/login?next=%2F'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private mode + anonymous on `/login`: passes through without redirect', async () => {
|
||||||
|
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||||
|
const data = await callLoad(event('/login'));
|
||||||
|
expect(data.authConfig.private_mode).toBe(true);
|
||||||
|
// me() must not run on the login page itself, otherwise anonymous
|
||||||
|
// visits make an extra round-trip every page load.
|
||||||
|
expect(me).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private mode + logged-in user: no redirect, returns authConfig', async () => {
|
||||||
|
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||||
|
vi.mocked(me).mockResolvedValue(aliceUser);
|
||||||
|
const data = await callLoad(event('/'));
|
||||||
|
expect(data.authConfig).toEqual(PRIVATE_CFG);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private mode + anonymous: preserves pathname AND search in next=', async () => {
|
||||||
|
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||||
|
vi.mocked(me).mockResolvedValue(null);
|
||||||
|
await expect(
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
load(event('/manga/abc', '?page=3') as any)
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 302,
|
||||||
|
location: '/login?next=%2Fmanga%2Fabc%3Fpage%3D3'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('private mode + anonymous on /register: redirects to /login (register is never reachable in private mode)', async () => {
|
||||||
|
vi.mocked(getAuthConfig).mockResolvedValue(PRIVATE_CFG);
|
||||||
|
vi.mocked(me).mockResolvedValue(null);
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
await expect(load(event('/register') as any)).rejects.toMatchObject({
|
||||||
|
status: 302,
|
||||||
|
location: '/login?next=%2Fregister'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAuthConfig failure: falls back to public-mode defaults, no redirect', async () => {
|
||||||
|
// The backend middleware is the source of truth for the gate;
|
||||||
|
// if the config probe blips, fail soft so a brief outage doesn't
|
||||||
|
// lock everyone out of even the login page. No private data
|
||||||
|
// can leak because the backend still 401s every request.
|
||||||
|
vi.mocked(getAuthConfig).mockRejectedValue(new Error('network'));
|
||||||
|
const data = await callLoad(event('/'));
|
||||||
|
expect(data.authConfig).toEqual({
|
||||||
|
self_register_enabled: true,
|
||||||
|
private_mode: false
|
||||||
|
});
|
||||||
|
expect(me).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user