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"
        ));
    }
}