auth_logging.rs 141 lignes · 4933 octets
use crate::error::AppError;
use crate::hooks::{AuditEvent, RustwardenHooks};
use crate::models::audit_log;
use chrono::Utc;
use sea_orm::{ActiveModelTrait, DatabaseConnection, Set};
use std::sync::Arc;
use uuid::Uuid;

/// Logging service for blocked authentication attempts.
///
/// Goals:
/// - Record blocked attempts (reason, email, timestamp)
/// - Never store secrets or passwords
/// - Never fail the main authentication flow if logging fails
/// - Notify consumers via `RustwardenHooks::on_audit_event`
///   when they have plugged in a sink (SIEM, Langfuse, etc.)
pub struct AuthLoggingService;

impl AuthLoggingService {
    /// Log a blocked login attempt (unverified email, etc.).
    ///
    /// When `hooks` is provided, also emits an `AuditEvent { kind: "auth.login_blocked" }`
    /// so that consumer applications can aggregate all audit data in a
    /// single pipeline without re-reading the `audit_logs` table.
    pub async fn log_blocked_login(
        db: &DatabaseConnection,
        email: &str,
        reason: &str,
        hooks: Option<&Arc<dyn RustwardenHooks>>,
    ) -> Result<(), AppError> {
        tracing::warn!("[LOGIN_BLOCKED] email={} reason={}", email, reason);

        // Insère dans audit_logs si disponible (best-effort)
        let log = audit_log::ActiveModel {
            id: Set(Uuid::new_v4()),
            user_id: Set(None),
            action: Set("login_blocked".to_string()),
            target_user_id: Set(None),
            level: Set("WARN".to_string()),
            message: Set(format!("email={} reason={}", email, reason)),
            created_at: Set(Utc::now()),
            actor_remote_identity_id: Set(None),
            target_remote_identity_id: Set(None),
        };

        if let Err(e) = log.insert(db).await {
            tracing::warn!("Impossible d’écrire le log d’auth: {}", e);
        }

        // Fan-out au hook generique si le consommateur en a branché un.
        // Un échec du hook ne doit pas casser le flux d'auth (best-effort).
        if let Some(hooks) = hooks {
            let metadata = serde_json::json!({ "email": email, "reason": reason });
            let event = AuditEvent {
                kind: "auth.login_blocked",
                actor_id: None,
                target: None,
                metadata: &metadata,
            };
            if let Err(e) = hooks.on_audit_event(db, event).await {
                tracing::warn!("RustwardenHooks::on_audit_event failed: {}", e);
            }
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::hooks::RustwardenHooks;
    use async_trait::async_trait;
    use std::sync::Mutex;

    /// Hooks impl that forwards every recorded event to an externally-owned
    /// `Arc<Mutex<Vec<_>>>`. Keeping the sink outside the trait object avoids
    /// any downcast when the `Arc<dyn RustwardenHooks>` is passed downstream.
    struct CapturingHooks {
        sink: Arc<Mutex<Vec<(String, serde_json::Value)>>>,
    }

    #[async_trait]
    impl RustwardenHooks for CapturingHooks {
        async fn on_audit_event(
            &self,
            _db: &DatabaseConnection,
            event: AuditEvent<'_>,
        ) -> anyhow::Result<()> {
            self.sink
                .lock()
                .unwrap()
                .push((event.kind.to_string(), event.metadata.clone()));
            Ok(())
        }
    }

    /// Per-test connection — see personal_token_service::tests for rationale.
    async fn test_db() -> DatabaseConnection {
        dotenvy::dotenv().ok();
        let url =
            std::env::var("DATABASE_URL").expect("DATABASE_URL required for auth_logging tests");
        sea_orm::Database::connect(&url).await.unwrap()
    }

    #[tokio::test]
    async fn log_blocked_login_dispatches_audit_event_when_hooks_provided() {
        let db = test_db().await;
        let sink: Arc<Mutex<Vec<(String, serde_json::Value)>>> = Arc::new(Mutex::new(Vec::new()));
        let hooks: Arc<dyn RustwardenHooks> = Arc::new(CapturingHooks { sink: sink.clone() });

        AuthLoggingService::log_blocked_login(
            &db,
            "test-audit@example.com",
            "email_not_verified",
            Some(&hooks),
        )
        .await
        .unwrap();

        let events = sink.lock().unwrap().clone();
        assert_eq!(events.len(), 1);
        assert_eq!(events[0].0, "auth.login_blocked");
        assert_eq!(events[0].1["email"], "test-audit@example.com");
        assert_eq!(events[0].1["reason"], "email_not_verified");
    }

    #[tokio::test]
    async fn log_blocked_login_works_without_hooks() {
        let db = test_db().await;
        // None → no hook dispatch, no panic.
        AuthLoggingService::log_blocked_login(
            &db,
            "no-hook@example.com",
            "email_not_verified",
            None,
        )
        .await
        .unwrap();
    }
}