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(); } }
GitRust