auth.rs
384 lignes · 14138 octets
//! Authentication configuration //! //! Loads and validates authentication configuration from environment variables. use std::env; /// Session backend storage type #[derive(Debug, Clone, PartialEq, Default)] pub enum SessionBackend { /// In-process memory store (default; not suitable for multi-instance deployments). #[default] Memory, /// Persist sessions in the database via SeaORM. SeaOrm, /// Persist sessions in a Redis instance. Redis, } impl std::str::FromStr for SessionBackend { type Err = String; fn from_str(s: &str) -> Result<Self, Self::Err> { match s.to_lowercase().as_str() { "memory" => Ok(Self::Memory), "seaorm" => Ok(Self::SeaOrm), "redis" => Ok(Self::Redis), _ => Err(format!( "Invalid SESSION_BACKEND: '{}'. Must be 'memory', 'seaorm' or 'redis'", s )), } } } /// Authentication configuration loaded from environment variables #[derive(Debug, Clone)] pub struct AuthConfig { /// Secret key used to sign and verify JWTs (env: `JWT_SECRET`, min 32 bytes). pub jwt_secret: String, /// JWT access token lifetime in minutes (env: `JWT_EXPIRATION_MINUTES`, default: 15). pub jwt_expiration_minutes: i64, /// Issuer claim embedded in JWTs (env: `JWT_ISSUER`, default: `"rustwarden"`). pub jwt_issuer: String, /// Refresh token lifetime in days (env: `REFRESH_TOKEN_EXPIRATION_DAYS`, default: 7). pub refresh_token_expiration_days: i64, /// Refresh token lifetime when "remember me" is enabled. /// Env variable: REMEMBER_ME_EXPIRATION_DAYS (default: 30) pub remember_me_expiration_days: i64, /// Idle session timeout in minutes (env: `SESSION_TIMEOUT_MINUTES`, default: 30). pub session_timeout_minutes: i64, /// Storage backend used for server-side sessions. pub session_backend: SessionBackend, /// Redis connection URL, required when `session_backend` is `Redis`. pub redis_url: Option<String>, /// Maximum login attempts per minute per IP (env: `RATE_LIMIT_LOGIN_PER_MINUTE`, default: 5). pub rate_limit_login_per_minute: u32, /// Maximum token-refresh attempts per minute per IP (env: `RATE_LIMIT_REFRESH_PER_MINUTE`, default: 10). pub rate_limit_refresh_per_minute: u32, /// Maximum general API requests per minute per IP (env: `RATE_LIMIT_GENERAL_PER_MINUTE`, default: 100). pub rate_limit_general_per_minute: u32, /// Whether the `Secure` flag is set on auth cookies. pub cookie_secure: bool, /// Value of the `SameSite` cookie attribute (env: `COOKIE_SAME_SITE`, default: `"Strict"`). pub cookie_same_site: String, /// Path the auth middleware redirects anonymous browser visitors to /// (env: `LOGIN_PATH`, default: `"/login"`). Decoupled from the UI so /// consumers with a custom login route — or none, in headless mode — /// are not forced onto `rustwarden-ui`'s `/login`. pub login_path: String, /// How the auth middleware answers a protected route reached without /// valid credentials (env: `AUTH_FAILURE_MODE`, default: `Redirect`). /// Headless apps set this to [`AuthFailureMode::Json401`] so browsers /// and API clients receive a `401` instead of a redirect to a login /// page that does not exist. pub auth_failure_mode: AuthFailureMode, } /// Returns `true` when `secret` is an exact match for a known example JWT secret. pub(crate) fn jwt_secret_is_placeholder(secret: &str) -> bool { const KNOWN_EXAMPLES: &[&str] = &[ "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes", "change-me-in-production", "super-secret-jwt-key", "my-secret-key", "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE", ]; KNOWN_EXAMPLES.contains(&secret) } impl AuthConfig { /// Load and validate authentication configuration from environment variables. pub fn from_env() -> Result<Self, String> { let jwt_secret = env::var("JWT_SECRET") .map_err(|_| "JWT_SECRET environment variable is required".to_string())?; // SEC-L1: Reject well-known example/placeholder secrets if jwt_secret_is_placeholder(&jwt_secret) { return Err( "JWT_SECRET is set to a known example value. Please generate a unique secret \ (e.g., `openssl rand -hex 32`)." .to_string(), ); } if jwt_secret.len() < 32 { return Err(format!( "JWT_SECRET must be at least 32 bytes (256 bits). Current length: {} bytes. \ Generate a secure key with: openssl rand -base64 32", jwt_secret.len() )); } let jwt_expiration_minutes = env::var("JWT_EXPIRATION_MINUTES") .ok() .and_then(|v| v.parse::<i64>().ok()) .unwrap_or(15); let jwt_issuer = env::var("JWT_ISSUER").unwrap_or_else(|_| "rustwarden".to_string()); let refresh_token_expiration_days = env::var("REFRESH_TOKEN_EXPIRATION_DAYS") .ok() .and_then(|v| v.parse::<i64>().ok()) .unwrap_or(7); let remember_me_expiration_days = env::var("REMEMBER_ME_EXPIRATION_DAYS") .ok() .and_then(|v| v.parse::<i64>().ok()) .unwrap_or(30); if remember_me_expiration_days < refresh_token_expiration_days { return Err(format!( "REMEMBER_ME_EXPIRATION_DAYS ({}) must be >= REFRESH_TOKEN_EXPIRATION_DAYS ({})", remember_me_expiration_days, refresh_token_expiration_days )); } if remember_me_expiration_days > 90 { return Err(format!( "REMEMBER_ME_EXPIRATION_DAYS ({}) must be <= 90 days", remember_me_expiration_days )); } if remember_me_expiration_days > 60 { tracing::warn!( "REMEMBER_ME_EXPIRATION_DAYS={} is greater than 60 days. Consider reducing for security.", remember_me_expiration_days ); } let session_timeout_minutes = env::var("SESSION_TIMEOUT_MINUTES") .ok() .and_then(|v| v.parse::<i64>().ok()) .unwrap_or(30); let session_backend_str = env::var("SESSION_BACKEND").unwrap_or_else(|_| "seaorm".to_string()); let session_backend = session_backend_str.parse::<SessionBackend>()?; let redis_url = env::var("REDIS_URL").ok(); if session_backend == SessionBackend::Redis && redis_url.is_none() { return Err( "REDIS_URL environment variable is required when SESSION_BACKEND=redis".to_string(), ); } let rate_limit_login_per_minute = env::var("RATE_LIMIT_LOGIN_PER_MINUTE") .ok() .and_then(|v| v.parse::<u32>().ok()) .unwrap_or(5); let rate_limit_refresh_per_minute = env::var("RATE_LIMIT_REFRESH_PER_MINUTE") .ok() .and_then(|v| v.parse::<u32>().ok()) .unwrap_or(10); let rate_limit_general_per_minute = env::var("RATE_LIMIT_GENERAL_PER_MINUTE") .ok() .and_then(|v| v.parse::<u32>().ok()) .unwrap_or(100); // SEC-M9: Force COOKIE_SECURE=true in production (APP_DEBUG=false) let app_debug = env::var("APP_DEBUG") .ok() .and_then(|v| v.parse::<bool>().ok()) .unwrap_or(false); let cookie_secure_env = env::var("COOKIE_SECURE") .ok() .and_then(|v| v.parse::<bool>().ok()); let cookie_secure = if !app_debug { if cookie_secure_env == Some(false) { tracing::warn!( "SEC-M9: COOKIE_SECURE=false ignored in production (APP_DEBUG=false). Forcing secure cookies." ); } true } else { tracing::warn!( "⚠ APP_DEBUG=true — running in DEVELOPMENT mode. Secure cookies disabled, \ relaxed cookie prefixes. DO NOT use in production." ); cookie_secure_env.unwrap_or(false) }; // SEC-L8: Default SameSite to Strict (matching security comments) let cookie_same_site = env::var("COOKIE_SAME_SITE").unwrap_or_else(|_| "Strict".to_string()); // Decouple the login redirect target from the UI crate. let login_path = env::var("LOGIN_PATH").unwrap_or_else(|_| "/login".to_string()); // Auth-failure behaviour; invalid values fall back to the default // (Redirect) after a warning rather than aborting startup. let auth_failure_mode = match env::var("AUTH_FAILURE_MODE") { Ok(raw) => raw.parse::<AuthFailureMode>().unwrap_or_else(|err| { tracing::warn!("{err}; falling back to AuthFailureMode::Redirect"); AuthFailureMode::Redirect }), Err(_) => AuthFailureMode::Redirect, }; tracing::info!( "Auth configuration loaded: JWT expiration={}min, Session backend={:?}, Session timeout={}min", jwt_expiration_minutes, session_backend, session_timeout_minutes ); if !cookie_secure { tracing::warn!( "COOKIE_SECURE is set to false. Enable HTTPS and set COOKIE_SECURE=true in production." ); } Ok(Self { jwt_secret, jwt_expiration_minutes, jwt_issuer, refresh_token_expiration_days, remember_me_expiration_days, session_timeout_minutes, session_backend, redis_url, rate_limit_login_per_minute, rate_limit_refresh_per_minute, rate_limit_general_per_minute, cookie_secure, cookie_same_site, login_path, auth_failure_mode, }) } /// Return the JWT expiration as a `chrono::Duration`. #[allow(dead_code)] pub fn jwt_expiration_duration(&self) -> chrono::Duration { chrono::Duration::minutes(self.jwt_expiration_minutes) } /// Return the refresh token expiration as a `chrono::Duration`. #[allow(dead_code)] pub fn refresh_token_expiration_duration(&self) -> chrono::Duration { chrono::Duration::days(self.refresh_token_expiration_days) } /// Return the session timeout as a `chrono::Duration`. #[allow(dead_code)] pub fn session_timeout_duration(&self) -> chrono::Duration { chrono::Duration::minutes(self.session_timeout_minutes) } } /// Behaviour of the authentication middleware when a protected route is /// reached without valid credentials. /// /// Selected per application: SSR/UI apps keep the default [`Redirect`], while /// headless (API-only) apps switch to [`Json401`] via /// [`crate::app::RustwardenBuilder::headless`] or the /// [`crate::app::RustwardenBuilder::auth_failure_mode`] setter. /// /// [`Redirect`]: AuthFailureMode::Redirect /// [`Json401`]: AuthFailureMode::Json401 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum AuthFailureMode { /// Reply with `303 See Other` to [`AuthConfig::login_path`], carrying the /// original path in a `redirect` query parameter. Suited to browser UIs. #[default] Redirect, /// Reply with `401 Unauthorized` and a JSON body. Suited to SPAs, mobile /// clients, and machine APIs where a redirect to an HTML login page is /// meaningless. Json401, } impl std::str::FromStr for AuthFailureMode { type Err = String; /// Parse the `AUTH_FAILURE_MODE` environment value (case-insensitive). fn from_str(s: &str) -> Result<Self, Self::Err> { match s.trim().to_ascii_lowercase().as_str() { "redirect" => Ok(Self::Redirect), "json401" | "json_401" | "json" | "401" => Ok(Self::Json401), other => Err(format!( "invalid AUTH_FAILURE_MODE '{other}' (expected 'redirect' or 'json401')" )), } } } #[cfg(test)] mod tests { use super::AuthFailureMode; use std::str::FromStr; #[test] fn auth_failure_mode_defaults_to_redirect() { assert_eq!(AuthFailureMode::default(), AuthFailureMode::Redirect); } #[test] fn auth_failure_mode_parses_known_values_case_insensitively() { assert_eq!( AuthFailureMode::from_str("redirect").unwrap(), AuthFailureMode::Redirect ); assert_eq!( AuthFailureMode::from_str("Json401").unwrap(), AuthFailureMode::Json401 ); assert_eq!( AuthFailureMode::from_str(" JSON ").unwrap(), AuthFailureMode::Json401 ); } #[test] fn auth_failure_mode_rejects_unknown_value() { assert!(AuthFailureMode::from_str("nope").is_err()); } } #[cfg(test)] mod jwt_secret_tests { use super::jwt_secret_is_placeholder; #[test] fn rejects_cli_placeholder() { assert!(jwt_secret_is_placeholder( "REMPLACER_PAR_UNE_CLE_SECRETE_LONGUE" )); } #[test] fn rejects_historical_placeholders() { const HISTORICAL: &[&str] = &[ "your-very-secret-jwt-key-change-me-in-production-at-least-32-bytes", "change-me-in-production", "super-secret-jwt-key", "my-secret-key", ]; for secret in HISTORICAL { assert!( jwt_secret_is_placeholder(secret), "expected placeholder rejection for {secret}" ); } } #[test] fn accepts_passphrase_containing_change_me() { assert!(!jwt_secret_is_placeholder( "my-change-me-not-secret-passphrase-2026xxxx" )); } #[test] fn accepts_random_hex_64() { assert!(!jwt_secret_is_placeholder( "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" )); } }
GitRust