auth_service.rs 353 lignes · 14441 octets
/// Authentication service for user login and credential verification
///
/// This service handles authentication logic including:
/// - Credential verification (username/password)
/// - Password validation against database
/// - User lookup and status checks
use crate::error::AppError;
use crate::models::user;
use crate::services::password_service::PasswordService;
use sea_orm::{ColumnTrait, DatabaseConnection, EntityTrait, QueryFilter};
use std::sync::LazyLock;

/// SEC-C1: Pre-computed dummy bcrypt hash used when a user is not found,
/// ensuring constant response time regardless of user existence.
// Note: This static initialization uses expect() intentionally — if bcrypt
// fails to hash a constant string at startup, the application cannot function
// securely and must fail immediately.
// SEC-FIX: Use the same configurable cost as real passwords to prevent timing attacks.
static DUMMY_HASH: LazyLock<String> = LazyLock::new(|| {
    let cost = std::env::var("BCRYPT_COST")
        .ok()
        .and_then(|v| v.parse::<u32>().ok())
        .unwrap_or(bcrypt::DEFAULT_COST);
    bcrypt::hash("__dummy_timing_pad__", cost.clamp(12, 20)).expect("bcrypt init failed")
});

fn mask_username(username: &str) -> String {
    if username.len() <= 2 {
        "***".to_string()
    } else {
        let visible = (username.len() / 3).max(1);
        format!("{}***", &username[..visible])
    }
}

/// Service for authentication operations
pub struct AuthService;

impl AuthService {
    /// Verify user credentials and return the user if valid
    ///
    /// The `identifier` parameter accepts either a username or an email address.
    /// If it contains `@`, lookup is done by email; otherwise by username.
    ///
    /// # Security
    ///
    /// - SEC-C1: Always runs bcrypt verify (even for non-existent users) to prevent timing attacks
    /// - SEC-L5: Identifiers are partially masked in log output
    /// - Returns generic error message (never reveals if identifier or password is wrong)
    pub async fn verify_credentials(
        db: &DatabaseConnection,
        identifier: &str,
        password: &str,
    ) -> Result<user::Model, AppError> {
        let user = if identifier.contains('@') {
            user::Entity::find()
                .filter(user::Column::Email.eq(identifier))
                .one(db)
                .await?
        } else {
            user::Entity::find()
                .filter(user::Column::Username.eq(identifier))
                .one(db)
                .await?
        };

        let user = match user {
            Some(u) => u,
            None => {
                // SEC-C1: always run bcrypt to prevent timing-based user enumeration
                let _ = PasswordService::verify_password("invalid", &DUMMY_HASH).await;
                tracing::warn!(
                    "Login attempt for unknown identifier: {}",
                    mask_username(identifier)
                );
                return Err(AppError::InvalidCredentials);
            }
        };

        let password_valid =
            PasswordService::verify_password(password, &user.password_hash).await?;

        if !password_valid {
            tracing::warn!(
                "Login attempt with invalid password for identifier: {}",
                mask_username(identifier)
            );
            return Err(AppError::InvalidCredentials);
        }

        tracing::info!(
            "User {} authenticated successfully",
            mask_username(identifier)
        );
        Ok(user)
    }

    /// Authenticate a user for login (credentials + email-verified policy).
    ///
    /// The `identifier` parameter accepts either a username or an email address.
    ///
    /// This is the single authentication gate used by the HTTP `login`
    /// handler and by any consumer application (SSR, custom handlers) that
    /// calls the service directly. It performs, in order:
    ///
    /// 1. [`Self::verify_credentials`] — timing-safe password check.
    /// 2. Logs when `must_change_password` is set (does NOT block login).
    ///    The caller is responsible for surfacing the flag so the frontend
    ///    can redirect the user to a password-change page.
    /// 3. Email-verified policy (RFC 0040): when the app setting
    ///    `validation_email_required` (legacy alias
    ///    `validation_email_requise`, default `true`) is enabled and the
    ///    user's email is not verified, the attempt is recorded via
    ///    [`log_blocked_login`](crate::services::auth_logging::AuthLoggingService::log_blocked_login)
    ///    and rejected with [`AppError::Validation`]. A settings lookup
    ///    failure is treated as "verification required" (fail-closed).
    ///
    /// Use [`Self::verify_credentials`] directly for password-only
    /// re-authentication flows (e.g. TOTP setup) that must not apply the
    /// email-verified policy.
    ///
    /// # Errors
    ///
    /// - [`AppError::InvalidCredentials`] — unknown identifier or wrong
    ///   password (checked before the email policy, so there is no
    ///   "account exists but unverified" oracle).
    /// - [`AppError::Validation`] — email not verified while the policy
    ///   is active.
    ///
    /// # Example
    ///
    /// ```ignore
    /// use rustwarden_core::services::auth_service::AuthService;
    ///
    /// // Requires a live database connection.
    /// let user = AuthService::authenticate_user(&db, "alice", "s3cret-pass!").await?;
    /// assert!(user.email_verified);
    /// ```
    pub async fn authenticate_user(
        db: &DatabaseConnection,
        identifier: &str,
        password: &str,
    ) -> Result<user::Model, AppError> {
        let user = Self::verify_credentials(db, identifier, password).await?;

        if user.must_change_password {
            tracing::info!("User {} should change password (flag set)", user.username);
        }

        // RFC 0040: enforce the email-verified policy at the service level so
        // direct service callers cannot bypass it. Fail-closed: if the
        // settings lookup errors, verification is required.
        let email_validation_required =
            crate::services::app_settings_service::AppSettingsService::get_bool_with_fallback(
                db,
                &EMAIL_VALIDATION_SETTING_KEYS,
                true,
            )
            .await
            .unwrap_or(true);

        if email_validation_required && !user.email_verified {
            tracing::warn!(
                "Login attempt blocked (unverified email): {} ({})",
                user.username,
                user.email
            );
            // Best-effort audit trail, never stores secrets. RFC 0040 v1:
            // no `hooks` parameter — the audit_logs row is still written.
            // Failures are already traced inside `log_blocked_login`.
            let _ = crate::services::auth_logging::AuthLoggingService::log_blocked_login(
                db,
                &user.email,
                "email_not_verified",
                None,
            )
            .await;
            return Err(AppError::Validation(
                "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.".to_string(),
            ));
        }

        Ok(user)
    }
}

/// App-settings keys (current name + legacy French alias) controlling whether
/// login requires a verified email (RFC 0040). Shared with the test helpers.
const EMAIL_VALIDATION_SETTING_KEYS: [&str; 2] =
    ["validation_email_required", "validation_email_requise"];

#[cfg(test)]
mod tests {
    //! RFC 0040 tests: `authenticate_user` enforces the email-verified policy.
    //!
    //! Note on the "setting off" case (`validation_email_required=false` →
    //! unverified user logs in): it is intentionally NOT covered here. The
    //! setting is global shared state in the dev database; tests run in
    //! parallel, so flipping it would race with the default-policy test below
    //! (and with any other login test), and a panic between "set false" and
    //! "restore" would silently leave the security policy disabled in the dev
    //! DB. The fail-closed default (`unwrap_or(true)`, absent → required) is
    //! covered by `authenticate_user_blocks_unverified_email_when_policy_active`.
    //!
    //! Also intentionally not covered: the RFC's `DbErr` lookup-failure case
    //! (`unwrap_or(true)` makes it equivalent to the absent-setting case above)
    //! and the admin-bootstrap login (the seed sets `email_verified=true`,
    //! asserted in `default_admin_service`).

    use super::*;
    use crate::models::audit_log;
    use crate::services::app_settings_service::AppSettingsService;
    use crate::services::user_service::UserService;
    use sea_orm::{ActiveModelTrait, ModelTrait, Set};
    use uuid::Uuid;

    async fn test_db() -> DatabaseConnection {
        dotenvy::dotenv().ok();
        let url =
            std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_service tests");
        sea_orm::Database::connect(&url).await.unwrap()
    }

    const TEST_PASSWORD: &str = "TestPassword1!";

    /// Exact user-facing message returned by `authenticate_user` when the
    /// email-verified policy blocks the login. The UI displays this string
    /// verbatim (`err.error` / `err.message`) — keep in sync with the
    /// implementation.
    const UNVERIFIED_MSG: &str = "Votre email n'est pas vérifié. Cliquez sur « Renvoyer l'email de validation » pour recevoir un nouveau lien.";

    /// Create a unique test user. `create_user` leaves `email_verified=false`.
    async fn create_test_user(db: &DatabaseConnection) -> user::Model {
        let unique = Uuid::new_v4().simple().to_string();
        let hash = PasswordService::hash_password(TEST_PASSWORD).await.unwrap();
        UserService::create_user(
            db,
            format!("authsvc-{}", &unique[..8]),
            format!("authsvc-{}@example.com", &unique[..8]),
            hash,
            false,
        )
        .await
        .unwrap()
    }

    /// True when the email-verified policy is currently effective in the
    /// shared dev DB (setting absent → default true, i.e. fail-closed).
    async fn email_policy_active(db: &DatabaseConnection) -> bool {
        AppSettingsService::get_bool_with_fallback(db, &EMAIL_VALIDATION_SETTING_KEYS, true)
            .await
            .unwrap_or(true)
    }

    // ── happy path: verified user, valid password ───────────────────────────

    #[tokio::test]
    async fn authenticate_user_ok_when_email_verified() {
        let db = test_db().await;
        let created = create_test_user(&db).await;
        let mut active: user::ActiveModel = created.into();
        active.email_verified = Set(true);
        let user = active.update(&db).await.unwrap();

        let result = AuthService::authenticate_user(&db, &user.username, TEST_PASSWORD).await;
        assert!(
            result.is_ok(),
            "verified user with valid password must authenticate: {:?}",
            result.err()
        );
        assert_eq!(result.unwrap().id, user.id);

        user.delete(&db).await.unwrap();
    }

    // ── wrong password: fails before the email policy (no oracle) ──────────

    #[tokio::test]
    async fn authenticate_user_wrong_password_no_email_oracle() {
        let db = test_db().await;
        // Unverified account: a wrong password must yield the generic
        // InvalidCredentials, never the Validation "email not verified"
        // message (which would confirm the account exists).
        let user = create_test_user(&db).await;

        match AuthService::authenticate_user(&db, &user.username, "WrongPassword1!").await {
            Err(AppError::InvalidCredentials) => {}
            other => panic!("expected InvalidCredentials, got {:?}", other),
        }

        user.delete(&db).await.unwrap();
    }

    // ── policy: unverified email blocked + audit_logs row ───────────────────

    #[tokio::test]
    async fn authenticate_user_blocks_unverified_email_when_policy_active() {
        let db = test_db().await;
        // The policy setting is global shared state: skip (never mutate it)
        // if an operator disabled it in this dev DB.
        if !email_policy_active(&db).await {
            eprintln!("skipping: validation_email_required disabled in this dev DB");
            return;
        }

        let user = create_test_user(&db).await;
        assert!(!user.email_verified, "create_user must start unverified");

        match AuthService::authenticate_user(&db, &user.username, TEST_PASSWORD).await {
            Err(AppError::Validation(msg)) => assert_eq!(msg, UNVERIFIED_MSG),
            other => panic!("expected AppError::Validation, got {:?}", other),
        }

        // A `login_blocked` / `email_not_verified` audit row must be written.
        let logs = audit_log::Entity::find()
            .filter(audit_log::Column::Action.eq("login_blocked"))
            .filter(audit_log::Column::Message.contains(&user.email))
            .all(&db)
            .await
            .unwrap();
        assert!(
            logs.iter()
                .any(|l| l.message.contains("email_not_verified")),
            "expected an audit_logs row login_blocked/email_not_verified for {}",
            user.email
        );

        // Clean up only the rows this test created (unique email).
        for log in logs {
            log.delete(&db).await.unwrap();
        }
        user.delete(&db).await.unwrap();
    }

    // ── non-regression: verify_credentials has no email policy (TOTP path) ──

    #[tokio::test]
    async fn verify_credentials_ok_for_unverified_email() {
        let db = test_db().await;
        let user = create_test_user(&db).await; // email_verified = false

        // Password-only re-auth (TOTP setup) must keep working for
        // unverified accounts.
        let result = AuthService::verify_credentials(&db, &user.username, TEST_PASSWORD).await;
        assert!(
            result.is_ok(),
            "verify_credentials must not apply the email-verified policy: {:?}",
            result.err()
        );

        user.delete(&db).await.unwrap();
    }
}