use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; use chrono::{Duration, Utc}; use serde::{Deserialize, Serialize}; use std::time::Duration as StdDuration; use crate::error::AppError; use crate::pb::email::{EmailAddress, SendEmailRequest}; use crate::service::AuthService; use crate::session::Session; #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct ResetPasswordRequest { pub email: String, } #[derive(Debug, Clone, Deserialize, Serialize, utoipa::ToSchema)] pub struct ResetPasswordVerifyParams { pub token: String, pub password: String, } #[derive(Debug, Clone, Serialize, Deserialize, utoipa::ToSchema)] struct PendingResetPassword { user_uid: uuid::Uuid, created_at: chrono::DateTime, } impl AuthService { const RESET_PASS_PREFIX: &'static str = "auth:reset_pass:"; const RESET_PASS_EXPIRY_HOURS: i64 = 1; const RESET_PASS_EXPIRY_SECS: u64 = 60 * 60; const RESET_PASS_COOLDOWN_SECS: u64 = 60; // 60 seconds between requests const RESET_PASS_DAILY_LIMIT: u64 = 5; // Max 5 requests per day const RESET_PASS_DAILY_SECS: u64 = 24 * 60 * 60; // 24 hours pub async fn auth_reset_password_request( &self, params: ResetPasswordRequest, ) -> Result<(), AppError> { let email = params.email.trim().to_lowercase(); // Rate limiting: check cooldown let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email); if self.ctx.cache.exists(&cooldown_key) { tracing::warn!(email = %email, "Password reset request rate limited (cooldown)"); return Ok(()); // Don't reveal if email exists } // Rate limiting: check daily limit let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email); let daily_count: u64 = self.ctx.cache.get(&daily_key).unwrap_or(0); if daily_count >= Self::RESET_PASS_DAILY_LIMIT { tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)"); return Ok(()); // Don't reveal if email exists } let user = self.auth_find_user_by_email(&email).await.ok(); if let Some(user) = user { let token = super::generate_token("rst"); let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, token); let now = chrono::Utc::now(); if let Err(e) = self.ctx.cache.set( &cache_key, &PendingResetPassword { user_uid: user.id, created_at: now, }, Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)), ) { tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token"); return Ok(()); } // Set cooldown if let Err(e) = self.ctx.cache.set( &cooldown_key, &true, Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)), ) { tracing::warn!(error = %e, "Failed to set cooldown"); } // Increment daily counter let new_count = daily_count + 1; if let Err(e) = self.ctx.cache.set( &daily_key, &new_count, Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)), ) { tracing::warn!(error = %e, "Failed to increment daily counter"); } let domain = match self.ctx.config.main_domain() { Ok(d) => d, Err(e) => { tracing::error!(error = %e, "Domain not configured for password reset"); return Ok(()); } }; let reset_link = format!("{}/auth/reset-password?token={}", domain, token); let mut mail = match self.ctx.registry.get_email_client() { Some(c) => c, None => { tracing::error!("mail service not available"); return Ok(()); } }; if let Err(e) = mail .send_email(tonic::Request::new(SendEmailRequest { to: vec![EmailAddress { email: email.clone(), name: String::new(), }], subject: "Reset Your Password".into(), text_body: format!( "You requested to reset your password.\n\n\ Reset your password here:\n\n{}\n\n\ If you did not request this, ignore this email.", reset_link ), ..Default::default() })) .await { tracing::error!(error = %e, email = %email, "Failed to send password reset email"); } tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent"); } Ok(()) } pub async fn auth_reset_password_verify( &self, context: &Session, params: ResetPasswordVerifyParams, ) -> Result<(), AppError> { if params.token.is_empty() { return Err(AppError::InvalidResetToken); } let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, params.token); let pending = self .ctx .cache .get::(&cache_key) .ok_or(AppError::InvalidResetToken)?; if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) { let _ = self.ctx.cache.delete(&cache_key); return Err(AppError::ResetTokenExpired); } let password = self.auth_rsa_decode(context, params.password).await?; crate::service::util::validate_password_strength(&password)?; let _ = self.ctx.cache.delete(&cache_key); let salt = SaltString::generate(&mut rand::thread_rng()); let password_hash = Argon2::default() .hash_password(password.as_bytes(), &salt) .map_err(|e| AppError::PasswordHashError(e.to_string()))? .to_string(); let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE user_password SET password_hash = $1, password_updated_at = $2, updated_at = $2 \ WHERE user_id = $3", ) .bind(&password_hash) .bind(now) .bind(pending.user_uid) .execute(self.ctx.db.writer()) .await .map_err(AppError::Database)?; if result.rows_affected() == 0 { return Err(AppError::InvalidResetToken); } tracing::info!(user_uid = %pending.user_uid, "Password reset successfully"); Ok(()) } }