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:
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);
|
||||
}
|
||||
Reference in New Issue
Block a user