auth_service.rs
931 lignes · 39360 octets
/// Auth Service Middleware /// /// Tower Service wrapper that applies auth_required middleware to protected routes. /// This works around Axum 0.8 Sync limitations with from_fn_with_state. use axum::{ extract::Request, http::{StatusCode, Uri}, response::{IntoResponse, Redirect, Response}, }; use sea_orm::DatabaseConnection; use std::{ future::Future, pin::Pin, sync::{Arc, Once}, task::{Context, Poll}, }; use tower::Service; use crate::config::auth::{AuthConfig, AuthFailureMode}; /// SEC-C4: Sanitize redirect URL to prevent open redirect attacks. /// Only allows relative paths starting with '/' (not '//'). /// The path is URL-encoded to prevent parameter injection. /// /// `login_path` is the destination anonymous visitors are sent to, /// configurable via [`AuthConfig::login_path`] so the middleware is not /// hard-wired to `rustwarden-ui`'s `/login`. The original target is preserved /// in a `redirect` query parameter when it is a safe relative path. pub(crate) fn sanitize_redirect(uri: &str, login_path: &str) -> String { let path = uri.replace('\\', "/"); if path.starts_with('/') && !path.starts_with("//") && !path.contains('\0') { format!("{login_path}?redirect={}", urlencoding::encode(&path)) } else { login_path.to_string() } } /// Response returned when a protected route is reached without valid /// credentials, honouring [`AuthConfig::auth_failure_mode`]. /// /// - [`AuthFailureMode::Redirect`] → `303 See Other` to /// [`AuthConfig::login_path`] (browser UIs). /// - [`AuthFailureMode::Json401`] → `401 Unauthorized` with a JSON body /// (headless / API apps, where redirecting to an HTML login page is /// meaningless). pub(crate) fn auth_failure_response(auth_config: &AuthConfig, uri: &Uri) -> Response { match auth_config.auth_failure_mode { AuthFailureMode::Json401 => ( StatusCode::UNAUTHORIZED, axum::Json(serde_json::json!({ "error": "unauthorized" })), ) .into_response(), AuthFailureMode::Redirect => { let url = sanitize_redirect(uri.path(), &auth_config.login_path); Redirect::to(&url).into_response() } } } /// Same as [`auth_failure_response`], for call sites that only hold request /// extensions (extractors, `from_fn` middlewares that don't pre-resolve the /// config). Resolves [`AuthConfig`] from the injected `Extension`, else from /// the environment, else falls back to a default `Redirect` to `/login`. pub(crate) fn auth_failure_from_extensions( extensions: &axum::http::Extensions, uri: &Uri, ) -> Response { let config = extensions .get::<Arc<AuthConfig>>() .cloned() .or_else(|| AuthConfig::from_env().ok().map(Arc::new)); match config { Some(cfg) => auth_failure_response(&cfg, uri), None => { let url = sanitize_redirect(uri.path(), "/login"); Redirect::to(&url).into_response() } } } /// Fail-closed outcome of a SeaORM `find_by_id` (or equivalent) user lookup. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum UserLookup { /// A row was found; the caller still owns the `Model`. Found, /// No row: treat as unauthenticated (deleted user + leftover JWT). Missing, /// Database error: refuse the request (503). Unavailable, } /// Map a SeaORM lookup result to a fail-closed auth decision. pub(crate) fn map_user_lookup<T, E>(result: Result<Option<T>, E>) -> UserLookup { match result { Ok(Some(_)) => UserLookup::Found, Ok(None) => UserLookup::Missing, Err(_) => UserLookup::Unavailable, } } /// Fail-closed outcome of a forced-2FA TOTP status check. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum TotpGate { /// User already has TOTP enabled; continue. Allow, /// Forced 2FA is on and the user has not enrolled; redirect to setup. RedirectSetup, /// Database error: refuse the request (503). Unavailable, } /// Map `TotpService::is_totp_enabled` to a fail-closed forced-2FA decision. pub(crate) fn totp_force_gate(result: Result<bool, impl std::fmt::Display>) -> TotpGate { match result { Ok(true) => TotpGate::Allow, Ok(false) => TotpGate::RedirectSetup, Err(_) => TotpGate::Unavailable, } } /// Tower middleware that injects the authenticated user into request extensions. /// /// The middleware splits incoming requests into two branches based on /// whether the request path matches any of the registered /// `protected_prefixes`: /// /// - **Protected branch**: authentication is mandatory. A missing or /// invalid JWT triggers a `303 See Other` redirect to /// `/login?redirect=<sanitized-path>`. Additional checks run here /// (JWT blacklist, `must_change_password`, forced 2FA, remote /// identity revocation). /// - **Optional auth branch**: authentication is best-effort. The /// request is allowed through regardless, but when a valid JWT is /// present the claims are still injected into /// `request.extensions()` (`Claims` and the JSON-serialised form) /// so handlers can adapt to authenticated users via /// `Option<Extension<Claims>>`. /// /// Both branches resolve the [`crate::config::auth::AuthConfig`] /// required for JWT verification via the same three-step lookup /// (RFC 0042): /// /// 1. The `Arc<AuthConfig>` captured at construction time by /// [`AuthService::with_auth_config`]. This is the authoritative /// source: it is immune to tower layer ordering, so builder /// overrides (`login_path`, `auth_failure_mode`, `headless`) are /// always honoured. /// 2. `Extension(Arc<AuthConfig>)` read from the request extensions /// when a downstream layer has posed it. /// 3. [`crate::config::auth::AuthConfig::from_env`] as a last resort, /// reported once per process through a `tracing::warn!`. /// /// Steps 2 and 3 are the **legacy path of the deprecated /// [`AuthService::new`] constructor**. They silently drop builder /// overrides whenever the `Extension` layer sits further inside the /// stack than this middleware — which is precisely what /// [`crate::app::RustwardenBuilder::build`] does, since both services /// are layered outside `Extension(Arc<AuthConfig>)`. The protected /// branch was patched first (commit `d4d6828`, RFC 0022); the optional /// branch was aligned by RFC 0031; RFC 0042 added step 1. /// /// On the protected branch, a failed resolution produces a /// `500 Internal Server Error` after a `tracing::error!`. On the /// optional branch, the request simply traverses without claims — /// matching the semantics of an unauthenticated visitor — and a /// `tracing::error!` records the misconfiguration. /// /// See [`docs/auth-middleware-layering.md`](../../../../../docs/auth-middleware-layering.md) /// for the consumer-facing explanation of layer ordering and /// `register_protected_prefix` usage. #[derive(Clone)] pub struct AuthService<S> { inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>, /// Config captured at construction. `None` only on the deprecated /// [`AuthService::new`] path, where the request extension / environment /// fallback applies. auth_config: Option<Arc<AuthConfig>>, } impl<S> AuthService<S> { /// Create a new `AuthService` middleware wrapping the given inner service. /// /// `protected_prefixes` is the list of URL path prefixes for which the /// middleware enforces JWT authentication. Filled in by /// [`crate::app::RustwardenBuilder::register_protected_prefix`] (and /// the workspace defaults `/home`, `/profile`, …). Companion crates /// such as `rustwarden-fediverse` register their own prefixes /// (`/fediverse/consent`, `/fediverse/invite`) via the builder before /// the app boots. #[deprecated( since = "1.2.0", note = "use `AuthService::with_auth_config(inner, db, prefixes, auth_config)`; the per-request `AuthConfig::from_env()` fallback ignores builder overrides (`login_path`, `auth_failure_mode`, `headless`). Removal no earlier than 1.5.0, effective in the next major." )] pub fn new(inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>) -> Self { Self { inner, db, protected_prefixes, auth_config: None, } } /// Create an `AuthService` that carries its own resolved /// [`AuthConfig`] — the supported constructor since RFC 0042. /// /// The config is captured **once at construction**, the same pattern /// `security_headers_middleware` uses in /// [`crate::app::RustwardenBuilder::build`]. Consequences: /// /// - Builder overrides (`login_path`, `auth_failure_mode`, `headless`) /// are honoured regardless of tower layer order. A middleware layered /// *outside* `Extension(Arc<AuthConfig>)` can never observe that /// extension, which is what made [`AuthService::new`] fall back to /// [`AuthConfig::from_env`] and discard those overrides. /// - The captured config takes precedence over any /// `Extension(Arc<AuthConfig>)` present in the request. /// - On every request the middleware inserts the captured `Arc` into the /// request extensions, so downstream extractors (`AuthUser`, /// `auth_required`, `admin_required`) observe the same instance even in /// hand-composed tower stacks that have no `Extension` layer of their /// own. In the canonical wiring the inner `Extension` layer re-inserts /// the very same `Arc`, which is a no-op. /// /// `protected_prefixes` has the same meaning as in /// [`AuthService::new`]. /// /// ```no_run /// # use std::sync::Arc; /// # use rustwarden_core::config::auth::AuthConfig; /// # use rustwarden_core::middleware::auth_service::AuthService; /// # fn wire(db: sea_orm::DatabaseConnection, router: axum::Router) -> Result<(), String> { /// let auth_config = Arc::new(AuthConfig::from_env()?); /// let prefixes = Arc::new(vec!["/api/private".to_string()]); /// let app = router.layer(tower::layer::layer_fn(move |inner| { /// AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), auth_config.clone()) /// })); /// # let _ = app; Ok(()) } /// ``` pub fn with_auth_config( inner: S, db: DatabaseConnection, protected_prefixes: Arc<Vec<String>>, auth_config: Arc<AuthConfig>, ) -> Self { Self { inner, db, protected_prefixes, auth_config: Some(auth_config), } } } /// Emitted at most once per process when the deprecated resolution path /// reaches [`AuthConfig::from_env`] (RFC 0042, OQ5). static DEPRECATED_CONFIG_PATH_WARNING: Once = Once::new(); /// Resolve the [`AuthConfig`] a request must be authenticated against. /// /// Authoritative config first; request extension then environment only on /// the deprecated path. A free function so it can be called from inside the /// `'static` boxed future returned by `call` (where `self` is unavailable). pub(crate) fn resolve_auth_config( captured: Option<&Arc<AuthConfig>>, extensions: &axum::http::Extensions, ) -> Result<Arc<AuthConfig>, String> { if let Some(config) = captured { return Ok(config.clone()); } if let Some(config) = extensions.get::<Arc<AuthConfig>>() { return Ok(config.clone()); } let config = AuthConfig::from_env().map(Arc::new)?; // Warn only once the fallback actually succeeded, so the message never // precedes a `from_env failed` error for the same request. DEPRECATED_CONFIG_PATH_WARNING.call_once(|| { tracing::warn!( "AuthConfig resolved from the environment: the deprecated \ `AuthService::new` / `AdminService::new` constructor path is in use, \ so builder overrides (login_path, auth_failure_mode, headless) are \ ignored. Switch to `with_auth_config(..)`. This warning is emitted \ once per process." ); }); Ok(config) } impl<S, B> Service<Request<B>> for AuthService<S> where S: Service<Request<B>, Response = Response> + Clone + Send + 'static, S::Future: Send + 'static, B: Send + 'static, { type Response = Response; type Error = S::Error; type Future = Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send>>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> { self.inner.poll_ready(cx) } fn call(&mut self, mut req: Request<B>) -> Self::Future { let uri = req.uri().clone(); let path = uri.path().to_owned(); // Seed the captured config into the request extensions so downstream // extractors (`AuthUser`, `auth_required`, `admin_required`) resolve // the exact same instance. In the canonical wiring the later // `Extension(Arc<AuthConfig>)` layer re-inserts the same `Arc`, which // is a no-op; seeding only matters for hand-composed tower stacks. if let Some(config) = &self.auth_config { req.extensions_mut().insert(config.clone()); } let captured = self.auth_config.clone(); // NOTE on `/api/v1/teams/*` : *not* protected here on purpose. // The canonical admin path is the UI handler // `crates/rustwarden-ui/src/handlers/admin_fediverse.rs::create_team_invite` // which is protected via `/admin/*` and passes `claims.sub` as // inviter directly. The `/api/v1/teams/*` route remains a // back-door consumed by E2E tests + future API integrators ; // its `invite()` handler accepts an `Option<Extension<Claims>>` // and falls back to `system_actor_user_id()` when no JWT is // present. A future RFC will gate this route once external // consumers are wired through a per-route auth extractor // independent of the UI middleware. let is_protected = self .protected_prefixes .iter() .any(|prefix| path.starts_with(prefix.as_str())); if is_protected { let db = self.db.clone(); let mut inner = self.inner.clone(); tracing::debug!("AuthService: intercepting protected route: {}", path); Box::pin(async move { let (parts, body) = req.into_parts(); use crate::auth::jwt::JwtService; use crate::services::jwt_blacklist_service::JwtBlacklistService; // Resolve AuthConfig up-front so every authentication-failure // branch below can honour `login_path` / `auth_failure_mode`. let auth_config = match resolve_auth_config(captured.as_ref(), &parts.extensions) { Ok(config) => config, Err(err) => { tracing::error!( "AuthService: AuthConfig unavailable (no captured config, no extension and from_env failed): {err}" ); return Ok(( axum::http::StatusCode::INTERNAL_SERVER_ERROR, "Internal server error", ) .into_response()); } }; let jwt_token = match crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { Ok(token) => { tracing::debug!("AuthService: JWT token found"); token } Err(_) => { tracing::warn!("AuthService: No JWT token found"); return Ok(auth_failure_response(&auth_config, &uri)); } }; match JwtBlacklistService::is_blacklisted(&db, &jwt_token).await { Ok(true) | Err(_) => { tracing::warn!("AuthService: JWT token is blacklisted"); return Ok(auth_failure_response(&auth_config, &uri)); } Ok(false) => {} } let claims = match JwtService::validate_token(&auth_config, &jwt_token) { Ok(claims) => claims, Err(_) => { tracing::warn!("AuthService: Invalid JWT token"); return Ok(auth_failure_response(&auth_config, &uri)); } }; // F10 (RFC 0018) — refuse remote JWTs whose backing // `remote_identities` row was disabled (kill_domain, // auto-kill, manual revoke). The check delegates to a // `RemoteIdentityRevocationChecker` injected by // `rustwarden-fediverse` as an Axum `Extension`. When the // federation crate isn't linked the extension is absent // and the branch is skipped — which is correct, because // remote JWT issuance is also gated by that crate. if claims.is_remote() { if let Some(rid) = claims.remote_identity_id() { if let Some(checker) = parts.extensions.get::<std::sync::Arc< dyn crate::middleware::revocation::RemoteIdentityRevocationChecker, >>() { if checker.is_revoked(rid).await { tracing::warn!( "AuthService: remote_identity {} is revoked, rejecting JWT", rid ); return Ok(auth_failure_response(&auth_config, &uri)); } } } } tracing::debug!("AuthService: User {} accessing: {}", claims.sub, uri); { use crate::middleware::update_last_login::extract_client_ip; use crate::models::user; use chrono::Utc; use sea_orm::{ActiveModelTrait, EntityTrait, Set}; let user_id = claims.sub; let headers = &parts.headers; let client_ip = extract_client_ip(headers); let lookup = user::Entity::find_by_id(user_id).one(&db).await; match map_user_lookup(lookup.as_ref().map(|row| row.as_ref())) { UserLookup::Unavailable => { if let Err(e) = &lookup { tracing::error!( %user_id, error = %e, "user lookup failed (fail-closed)" ); } return Ok((StatusCode::SERVICE_UNAVAILABLE, "Internal server error") .into_response()); } UserLookup::Missing => { return Ok(auth_failure_response(&auth_config, &uri)); } UserLookup::Found => { let Ok(Some(user_model)) = lookup else { return Ok(( StatusCode::SERVICE_UNAVAILABLE, "Internal server error", ) .into_response()); }; let must_change = user_model.must_change_password; let mut user_active: user::ActiveModel = user_model.into(); user_active.last_login_at = Set(Some(Utc::now())); if let Some(ip) = client_ip { user_active.last_login_ip = Set(Some(ip)); } if let Err(e) = user_active.update(&db).await { tracing::warn!( "Failed to update last_login for user {}: {}", user_id, e ); } else { tracing::debug!("Updated last_login for user {}", user_id); } if must_change && !path.starts_with("/settings/password") { tracing::info!( "User {} must change password, redirecting from {}", user_id, path ); return Ok(Redirect::to("/settings/password?must_change=true") .into_response()); } // Check if 2FA is forced globally and user hasn't set it up if !path.starts_with("/settings/security") && !path.starts_with("/api/v1/auth") { let totp_force_enabled = crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback( &db, &["totp_force_enabled"], false, ) .await .unwrap_or(false); if totp_force_enabled { let totp_result = crate::services::totp_service::TotpService::is_totp_enabled( &db, user_id, ) .await; if let Err(ref e) = totp_result { tracing::error!( %user_id, error = %e, "TOTP status lookup failed (fail-closed)" ); } match totp_force_gate(totp_result) { TotpGate::Allow => {} TotpGate::RedirectSetup => { tracing::info!( "User {} must setup 2FA, redirecting from {}", user_id, path ); return Ok(Redirect::to( "/settings/security?force_2fa=true", ) .into_response()); } TotpGate::Unavailable => { return Ok(( StatusCode::SERVICE_UNAVAILABLE, "Internal server error", ) .into_response()); } } } } } } } let mut parts_with_claims = parts; parts_with_claims.extensions.insert(claims.clone()); if let Ok(claims_json) = serde_json::to_string(&claims) { parts_with_claims.extensions.insert(claims_json); } tracing::debug!( "AuthService: Claims inserted into extensions for user {}", claims.sub ); let req_with_claims = Request::from_parts(parts_with_claims, body); inner.call(req_with_claims).await }) } else { // SEC-H9: For non-protected routes, still try to extract and propagate // JWT claims into extensions so that handlers/templates can detect the // logged-in user (optional auth). let db = self.db.clone(); let mut inner = self.inner.clone(); Box::pin(async move { let (mut parts, body) = req.into_parts(); if let Ok(jwt_token) = crate::auth::jwt_extractor::extract_jwt_from_request_parts(&parts) { use crate::auth::jwt::JwtService; use crate::services::jwt_blacklist_service::JwtBlacklistService; let is_blacklisted = JwtBlacklistService::is_blacklisted(&db, &jwt_token) .await .unwrap_or(true); if !is_blacklisted { // RFC 0031 / RFC 0042: mirror the protected branch // resolution so routes outside `protected_prefixes` // still see injected claims, whatever the tower // ordering of the `Extension(Arc<AuthConfig>)` layer. // Without it, `claims` was silently `None` for all // optional-auth routes. let auth_config = match resolve_auth_config(captured.as_ref(), &parts.extensions) { Ok(config) => Some(config), Err(e) => { tracing::error!( "AuthService optional_auth: AuthConfig fallback failed: {e}" ); None } }; if let Some(auth_config) = auth_config { if let Ok(claims) = JwtService::validate_token(&auth_config, &jwt_token) { parts.extensions.insert(claims.clone()); if let Ok(json) = serde_json::to_string(&claims) { parts.extensions.insert(json); } } } } } let req = Request::from_parts(parts, body); inner.call(req).await }) } } } #[cfg(test)] mod tests { use super::{ AuthService, TotpGate, UserLookup, auth_failure_response, map_user_lookup, sanitize_redirect, totp_force_gate, }; use crate::config::auth::{AuthConfig, AuthFailureMode, SessionBackend}; use axum::body::Body; use axum::http::{Request as HttpRequest, StatusCode, Uri, header::LOCATION}; use axum::response::Response; use sea_orm::{DatabaseBackend, DatabaseConnection, MockDatabase, Value}; use std::collections::BTreeMap; use std::sync::Arc; use tower::ServiceExt; // for `oneshot` /// Mock connection that answers no query: any DB access is a test failure /// (its transaction log is asserted empty by the "no token" tests). fn mock_db() -> DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres).into_connection() } /// Row shape SeaORM 2.0 expects for `Select::count()`: /// `SELECT COUNT(*) AS num_items FROM (..) AS sub_query`. fn blacklist_count(n: i64) -> BTreeMap<&'static str, Value> { BTreeMap::from([("num_items", Value::BigInt(Some(n)))]) } /// Mock connection whose single query result is the JWT-blacklist COUNT. fn mock_db_with_blacklist_count(n: i64) -> DatabaseConnection { MockDatabase::new(DatabaseBackend::Postgres) .append_query_results([vec![blacklist_count(n)]]) .into_connection() } /// Number of SQL statements the mock connection actually received. fn statement_count(db: DatabaseConnection) -> usize { db.into_transaction_log() .iter() .map(|txn| txn.statements().len()) .sum() } /// Router wrapped with `AuthService::with_auth_config` and **no** /// `Extension(Arc<AuthConfig>)` layer: the captured config is the only /// source of truth, exactly as RFC 0042 requires. fn auth_router(db: DatabaseConnection, prefixes: &[&str], cfg: AuthConfig) -> axum::Router { let prefixes = Arc::new( prefixes .iter() .map(|p| (*p).to_string()) .collect::<Vec<_>>(), ); let cfg = Arc::new(cfg); axum::Router::new() .route("/private", axum::routing::get(|| async { "private" })) .route( "/public", axum::routing::get( |axum::Extension(cfg): axum::Extension<Arc<AuthConfig>>| async move { cfg.login_path.clone() }, ), ) .layer(tower::layer::layer_fn(move |inner| { AuthService::with_auth_config(inner, db.clone(), prefixes.clone(), cfg.clone()) })) } async fn send(router: axum::Router, uri: &str, bearer: Option<&str>) -> Response { let mut builder = HttpRequest::builder().uri(uri); if let Some(token) = bearer { builder = builder.header("authorization", format!("Bearer {token}")); } let req = builder.body(Body::empty()).expect("request"); router.oneshot(req).await.expect("oneshot") } async fn body_string(resp: Response) -> String { let bytes = axum::body::to_bytes(resp.into_body(), 64 * 1024) .await .expect("body"); String::from_utf8(bytes.to_vec()).expect("utf-8 body") } fn location(resp: &Response) -> Option<&str> { resp.headers().get(LOCATION).and_then(|v| v.to_str().ok()) } /// T1 — protected prefix, no credentials, `Json401`: the captured config /// drives the 401 JSON response and no DB round-trip happens at all. #[tokio::test] async fn protected_route_without_jwt_returns_json_401_without_touching_the_database() { let db = mock_db(); let router = auth_router( db.clone(), &["/private"], test_config(AuthFailureMode::Json401, "/login"), ); let resp = send(router, "/private", None).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert!(location(&resp).is_none()); let body = body_string(resp).await; assert!(body.contains("unauthorized"), "unexpected body: {body}"); assert_eq!( statement_count(db), 0, "a missing token must short-circuit before any query" ); } /// T2 — protected prefix, no credentials, `Redirect`: the captured /// `login_path` override is honoured even though no `Extension` layer /// exists and no environment variable is set. #[tokio::test] async fn protected_route_redirect_mode_uses_the_captured_login_path() { let router = auth_router( mock_db(), &["/private"], test_config(AuthFailureMode::Redirect, "/auth/signin"), ); let resp = send(router, "/private", None).await; assert_eq!(resp.status(), StatusCode::SEE_OTHER); assert_eq!(location(&resp), Some("/auth/signin?redirect=%2Fprivate")); } /// T3 — the captured config is seeded into the request extensions, so a /// handler on a non-protected route can extract it although the test /// stack declares no `Extension` layer at all. #[tokio::test] async fn captured_config_is_seeded_into_the_request_extensions() { let router = auth_router( mock_db(), &["/private"], test_config(AuthFailureMode::Json401, "/auth/signin"), ); let resp = send(router, "/public", None).await; assert_eq!(resp.status(), StatusCode::OK); assert_eq!(body_string(resp).await, "/auth/signin"); } /// T4 — a JWT signed with a foreign secret must be rejected by /// `JwtService::validate_token` against the *captured* config. The /// blacklist COUNT is mocked to 0 so the flow actually reaches the /// signature check, and it is the only statement the DB sees. #[tokio::test] async fn protected_route_rejects_jwt_signed_with_another_secret() { use crate::auth::jwt::JwtService; let cfg = test_config(AuthFailureMode::Json401, "/login"); let mut foreign_cfg = test_config(AuthFailureMode::Json401, "/login"); foreign_cfg.jwt_secret = "fedcba9876543210fedcba9876543210".to_string(); let foreign_token = JwtService::generate_token( &foreign_cfg, uuid::Uuid::new_v4(), "intruder", Vec::new(), Vec::new(), ) .expect("foreign token"); assert!( JwtService::validate_token(&cfg, &foreign_token).is_err(), "the token must not verify against the captured config" ); let db = mock_db_with_blacklist_count(0); let router = auth_router(db.clone(), &["/private"], cfg); let resp = send(router, "/private", Some(&foreign_token)).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert_eq!( statement_count(db), 1, "only the JWT-blacklist COUNT should reach the database" ); } /// T5 — a JWT that *is* valid for the captured config is still refused /// when the blacklist COUNT reports it as revoked. #[tokio::test] async fn protected_route_rejects_blacklisted_but_valid_jwt() { use crate::auth::jwt::JwtService; let cfg = test_config(AuthFailureMode::Json401, "/login"); let token = JwtService::generate_token( &cfg, uuid::Uuid::new_v4(), "alice", vec!["user".to_string()], Vec::new(), ) .expect("token"); assert!( JwtService::validate_token(&cfg, &token).is_ok(), "the token must verify against the captured config" ); let db = mock_db_with_blacklist_count(1); let router = auth_router(db.clone(), &["/private"], cfg); let resp = send(router, "/private", Some(&token)).await; assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); let body = body_string(resp).await; assert!(body.contains("unauthorized"), "unexpected body: {body}"); assert_eq!( statement_count(db), 1, "the blacklist COUNT must short-circuit before the user lookup" ); } fn test_config(mode: AuthFailureMode, login_path: &str) -> AuthConfig { AuthConfig { jwt_secret: "0123456789abcdef0123456789abcdef".to_string(), jwt_expiration_minutes: 15, jwt_issuer: "test".to_string(), refresh_token_expiration_days: 7, remember_me_expiration_days: 30, session_timeout_minutes: 30, session_backend: SessionBackend::Memory, redis_url: None, rate_limit_login_per_minute: 5, rate_limit_refresh_per_minute: 10, rate_limit_general_per_minute: 100, cookie_secure: false, cookie_same_site: "Strict".to_string(), login_path: login_path.to_string(), auth_failure_mode: mode, } } #[test] fn sanitize_redirect_preserves_default_login_path() { assert_eq!( sanitize_redirect("/settings", "/login"), "/login?redirect=%2Fsettings" ); } #[test] fn sanitize_redirect_honors_custom_login_path() { assert_eq!( sanitize_redirect("/settings", "/auth/signin"), "/auth/signin?redirect=%2Fsettings" ); } #[test] fn sanitize_redirect_reduces_unsafe_target_to_bare_login_path() { assert_eq!( sanitize_redirect("//evil.example", "/auth/signin"), "/auth/signin" ); } #[test] fn sanitize_redirect_rejects_backslash_trick() { let out = sanitize_redirect("/\\evil.example", "/login"); assert_eq!(out, "/login"); } #[test] fn auth_failure_redirect_mode_points_to_configured_login_path() { let uri: Uri = "/settings".parse().unwrap(); let resp = auth_failure_response( &test_config(AuthFailureMode::Redirect, "/auth/signin"), &uri, ); assert_eq!(resp.status(), StatusCode::SEE_OTHER); let location = resp .headers() .get(LOCATION) .and_then(|v| v.to_str().ok()) .unwrap_or_default(); assert!( location.starts_with("/auth/signin"), "unexpected Location header: {location}" ); } #[test] fn auth_failure_json401_mode_returns_401_without_location() { let uri: Uri = "/settings".parse().unwrap(); let resp = auth_failure_response(&test_config(AuthFailureMode::Json401, "/login"), &uri); assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); assert!(resp.headers().get(LOCATION).is_none()); } #[test] fn map_user_lookup_found_when_row_present() { let result: Result<Option<()>, &str> = Ok(Some(())); assert_eq!(map_user_lookup(result), UserLookup::Found); } #[test] fn map_user_lookup_missing_when_no_row() { let result: Result<Option<()>, &str> = Ok(None); assert_eq!(map_user_lookup(result), UserLookup::Missing); } #[test] fn map_user_lookup_unavailable_on_error() { let result: Result<Option<()>, &str> = Err("db down"); assert_eq!(map_user_lookup(result), UserLookup::Unavailable); } #[test] fn totp_force_gate_allows_when_enabled() { let result: Result<bool, &str> = Ok(true); assert_eq!(totp_force_gate(result), TotpGate::Allow); } #[test] fn totp_force_gate_redirects_when_not_enrolled() { let result: Result<bool, &str> = Ok(false); assert_eq!(totp_force_gate(result), TotpGate::RedirectSetup); } #[test] fn totp_force_gate_unavailable_on_error() { let result: Result<bool, &str> = Err("db down"); assert_eq!(totp_force_gate(result), TotpGate::Unavailable); } }
GitRust