email_validation_service.rs
496 lignes · 17063 octets
//! Email validation service //! //! This service handles email validation functionality: //! - Generating secure validation tokens //! - Sending validation emails via queue //! - Validating tokens //! - Resending validation emails use crate::config::email::EmailConfig; use crate::error::AppError; use crate::models::{email_validation_token, user}; use crate::services::email_queue_service::EmailQueueService; use crate::services::rate_limit_service::RateLimitService; use crate::services::user_service::UserService; use chrono::{Duration, Utc}; use sea_orm::{ ActiveModelTrait, ColumnTrait, DatabaseConnection, EntityTrait, NotSet, QueryFilter, Set, }; use sha2::{Digest, Sha256}; use std::sync::OnceLock; use std::time::Duration as StdDuration; use uuid::Uuid; /// Default: 3 verification email requests per address per hour. const EMAIL_VALIDATION_RATE_LIMIT_MAX: u32 = 3; const EMAIL_VALIDATION_RATE_LIMIT_WINDOW: StdDuration = StdDuration::from_secs(3600); /// Process-wide sliding-window limiter for email-validation resends. /// /// Distinct from the password-reset limiter so the two flows keep separate /// budgets. Handlers construct [`EmailValidationService::new`] per request. static EMAIL_VALIDATION_LIMITER: OnceLock<RateLimitService> = OnceLock::new(); /// Return the process-wide email-validation limiter, initializing it once. fn shared_email_validation_limiter() -> RateLimitService { EMAIL_VALIDATION_LIMITER .get_or_init(|| { let limiter = RateLimitService::new(); if let Ok(handle) = tokio::runtime::Handle::try_current() { handle.spawn( limiter .clone() .cleanup_loop(StdDuration::from_secs(300), StdDuration::from_secs(3600)), ); } else { tracing::warn!( "email-validation limiter: no tokio runtime, cleanup_loop not started" ); } limiter }) .clone() } /// Email validation service pub struct EmailValidationService { email_config: EmailConfig, /// Sliding-window rate limiter (shared abstraction, see [`RateLimitService`]). rate_limiter: RateLimitService, } impl EmailValidationService { /// Create a new EmailValidationService using the process-wide rate limiter. pub fn new(email_config: EmailConfig) -> Self { Self { email_config, rate_limiter: shared_email_validation_limiter(), } } /// Build a service around an injected limiter (unit tests; avoids the process [`OnceLock`]). #[cfg(test)] pub(crate) fn new_with_limiter( email_config: EmailConfig, rate_limiter: RateLimitService, ) -> Self { Self { email_config, rate_limiter, } } /// Send validation email to a user /// /// # Arguments /// /// * `db` - Database connection /// * `user_id` - User ID to send validation email to /// /// # Returns /// /// Ok(()) if email was queued successfully /// /// # Errors /// /// Returns an error if: /// - User not found /// - Database operation fails /// - Email queue operation fails pub async fn send_validation_email( &self, db: &DatabaseConnection, user_id: Uuid, ) -> Result<(), AppError> { // Find user let user = UserService::get_user_by_id(db, user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; // Invalidate previous validation tokens for this user self.invalidate_previous_tokens(db, user_id).await?; // Generate secure token (UUID v4) let token = Uuid::new_v4().to_string(); // Hash token with SHA-256 let token_hash = Self::hash_token(&token); let expires_at = Utc::now() + Duration::hours(crate::config::tasks::email_validation_expiration_hours()); let validation_token = email_validation_token::ActiveModel { id: Set(Uuid::new_v4()), user_id: Set(user_id), token_hash: Set(token_hash), expires_at: Set(expires_at), used_at: NotSet, created_at: Set(Utc::now()), }; validation_token.insert(db).await?; // Generate validation URL let validation_url = format!( "{}/api/v1/auth/verify-email/{}", self.email_config.email_base_url, token ); // Render email template using Askama let locale = Some("fr".to_string()); // TODO: Get from user preferences or Accept-Language header let template = crate::templates::email_templates::EmailValidationTemplate::new( locale, user.username.clone(), validation_url.clone(), Some( self.email_config .smtp_from_name .clone() .unwrap_or_else(crate::config::app::default_app_name), ), ); // Render HTML and text versions let body_html = template .render_html() .map_err(|e| AppError::Internal(format!("Failed to render email template: {}", e)))?; let body_text = template.render_text(); let subject = match template.locale.as_str() { "fr" => "Vérification de votre adresse email", "de" => "Bestätigung Ihrer E-Mail-Adresse", "es" => "Verifique su dirección de correo electrónico", _ => "Verify Your Email Address", } .to_string(); // Enqueue email EmailQueueService::enqueue( db, user.email.clone(), Some(user.username.clone()), self.email_config.smtp_from.clone(), self.email_config.smtp_from_name.clone(), subject, body_html, body_text, "email_validation".to_string(), ) .await?; Ok(()) } /// Verify email using a validation token /// /// # Arguments /// /// * `db` - Database connection /// * `token` - Validation token from email /// /// # Returns /// /// User ID if token is valid and email was verified /// /// # Errors /// /// Returns an error if: /// - Token is invalid /// - Token is expired /// - Database operation fails /// /// # Note /// /// Unlike password reset tokens, validation tokens are idempotent /// (can be used multiple times, but we track first use) pub async fn verify_email( &self, db: &DatabaseConnection, token: &str, ) -> Result<Uuid, AppError> { // Hash token let token_hash = Self::hash_token(token); // Find token in database let validation_token = email_validation_token::Entity::find() .filter(email_validation_token::Column::TokenHash.eq(&token_hash)) .one(db) .await? .ok_or_else(|| AppError::Validation("Invalid verification token".to_string()))?; // Check if token is expired if validation_token.is_expired() { return Err(AppError::Validation( "Verification token has expired".to_string(), )); } // Mark token as used (idempotent: can be used multiple times, but we track first use) if validation_token.used_at.is_none() { let mut token_active: email_validation_token::ActiveModel = validation_token.clone().into(); token_active.used_at = Set(Some(Utc::now())); token_active.update(db).await?; } // Update user email_verified status let user = UserService::get_user_by_id(db, validation_token.user_id) .await? .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; if !user.email_verified { let mut user_active: user::ActiveModel = user.clone().into(); user_active.email_verified = Set(true); user_active.email_verified_at = Set(Some(Utc::now())); user_active.updated_at = Set(Utc::now()); user_active.update(db).await?; // Feature 011 - US5: Send welcome email when account is activated (FR-024) // Only send if welcome emails are enabled and this is the first verification let welcome_email_enabled = std::env::var("EMAIL_WELCOME_ENABLED") .unwrap_or_else(|_| "true".to_string()) .parse::<bool>() .unwrap_or(true); if welcome_email_enabled { // Generate login URL let login_url = format!("{}/login", self.email_config.email_base_url); // Render welcome email template let locale = Some("fr".to_string()); // TODO: Get from user preferences let template = crate::templates::email_templates::WelcomeEmailTemplate::new( locale.clone(), user.username.clone(), login_url, Some( self.email_config .smtp_from_name .clone() .unwrap_or_else(crate::config::app::default_app_name), ), ); // Render HTML and text versions if let (Ok(body_html), body_text) = (template.render_html(), template.render_text()) { // Subject based on locale let subject = match locale.as_deref() { Some("fr") => "Bienvenue sur RustWarden !", Some("de") => "Willkommen bei RustWarden!", Some("es") => "¡Bienvenido a RustWarden!", _ => "Welcome to RustWarden!", } .to_string(); // Enqueue welcome email (non-blocking - errors are logged but don't fail verification) if let Err(e) = crate::services::email_queue_service::EmailQueueService::enqueue( db, user.email.clone(), Some(user.username.clone()), self.email_config.smtp_from.clone(), self.email_config.smtp_from_name.clone(), subject, body_html, body_text, "welcome".to_string(), ) .await { tracing::warn!( "Failed to enqueue welcome email for user {}: {}", user.username, e ); } else { tracing::info!( "Welcome email queued for user {} (account activated)", user.username ); } } } } Ok(validation_token.user_id) } /// Resend validation email to a user /// /// # Arguments /// /// * `db` - Database connection /// * `email` - User email address /// /// # Returns /// /// `Ok(true)` if email was sent, `Ok(false)` if user doesn't exist or is already verified (security: don't reveal) /// /// # Errors /// /// Returns an error if: /// - Rate limit exceeded /// - Database operation fails /// - Email queue operation fails pub async fn resend_verification( &self, db: &DatabaseConnection, email: &str, ) -> Result<bool, AppError> { // Check rate limiting (3 requests per hour per email). Atomic check+record. self.rate_limiter.check_sliding_window( &format!("email_verify:{email}"), EMAIL_VALIDATION_RATE_LIMIT_MAX, EMAIL_VALIDATION_RATE_LIMIT_WINDOW, )?; // Find user by email let user = match UserService::get_user_by_email(db, email).await? { Some(u) => u, None => { // User doesn't exist - return Ok(false) but don't reveal this (security) return Ok(false); } }; // If user is already verified, don't send email (but don't reveal this) if user.email_verified { return Ok(false); } // Send validation email self.send_validation_email(db, user.id).await?; Ok(true) } /// Hash a token using SHA-256 fn hash_token(token: &str) -> String { let mut hasher = Sha256::new(); hasher.update(token.as_bytes()); format!("{:x}", hasher.finalize()) } /// Invalidate previous validation tokens for a user async fn invalidate_previous_tokens( &self, db: &DatabaseConnection, user_id: Uuid, ) -> Result<(), AppError> { // Find all unused tokens for this user let tokens = email_validation_token::Entity::find() .filter(email_validation_token::Column::UserId.eq(user_id)) .filter(email_validation_token::Column::UsedAt.is_null()) .all(db) .await?; // Mark them as used (invalidate) for token in tokens { let mut token_active: email_validation_token::ActiveModel = token.into(); token_active.used_at = Set(Some(Utc::now())); token_active.update(db).await?; } Ok(()) } } #[cfg(test)] mod tests { use super::*; use crate::config::email::EmailConfig; fn dummy_email_config() -> EmailConfig { EmailConfig { smtp_host: "localhost".into(), smtp_port: 587, smtp_user: "u".into(), smtp_password: "p".into(), smtp_from: "from@example.test".into(), smtp_from_name: None, smtp_use_tls: false, smtp_use_starttls: false, smtp_accept_invalid_certs: true, smtp_ca_cert_path: None, queue_batch_size: 10, queue_retry_attempts: 5, queue_retry_delay_seconds: 300, email_base_url: "http://localhost".into(), request_dsn: false, imap_host: None, imap_port: 993, imap_user: None, imap_password: None, imap_use_tls: true, imap_use_starttls: false, imap_accept_invalid_certs: false, imap_ca_cert_path: None, imap_mailbox: "INBOX".into(), imap_poll_interval_seconds: 300, } } #[test] fn new_with_limiter_uses_injected_instance() { let limiter = RateLimitService::new(); let svc = EmailValidationService::new_with_limiter(dummy_email_config(), limiter.clone()); for _ in 0..3 { limiter .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600)) .unwrap(); } assert!( svc.rate_limiter .check_sliding_window("email_verify:t@x.test", 3, StdDuration::from_secs(3600)) .is_err() ); } #[test] fn new_shares_process_wide_limiter() { let a = EmailValidationService::new(dummy_email_config()); let b = EmailValidationService::new(dummy_email_config()); for _ in 0..3 { a.rate_limiter .check_sliding_window( "email_verify:share-oncelock@test", 3, StdDuration::from_secs(3600), ) .unwrap(); } assert!( b.rate_limiter .check_sliding_window( "email_verify:share-oncelock@test", 3, StdDuration::from_secs(3600), ) .is_err() ); } #[test] fn new_with_limiter_is_independent_of_process_lock() { let private = RateLimitService::new(); let svc = EmailValidationService::new_with_limiter(dummy_email_config(), private.clone()); for _ in 0..3 { private .check_sliding_window("k", 3, StdDuration::from_secs(3600)) .unwrap(); } assert!( svc.rate_limiter .check_sliding_window("k", 3, StdDuration::from_secs(3600)) .is_err() ); let process = EmailValidationService::new(dummy_email_config()); assert!( process .rate_limiter .check_sliding_window("k", 3, StdDuration::from_secs(3600)) .is_ok() ); } }
GitRust