use argon2::{Argon2, PasswordHasher}; use chrono::{Duration, Utc}; use db::sqlx; use email::EmailMessage; use rand::{RngExt, distr::Alphanumeric}; use serde::{Deserialize, Serialize}; use session::Session; use crate::{AppService, error::AppError}; #[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)] struct PendingResetPassword { user_uid: uuid::Uuid, created_at: chrono::DateTime, } impl AppService { const RESET_PASS_PREFIX: &'static str = "auth:reset_pass:"; const RESET_PASS_EXPIRY_HOURS: i64 = 1; pub async fn auth_reset_password_request( &self, params: ResetPasswordRequest, ) -> Result<(), AppError> { let user = self.auth_find_user_by_email(¶ms.email).await.ok(); if let Some(user) = user { let token = Self::generate_reset_token(); let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, token); let now = chrono::Utc::now(); if let Err(e) = self .cache .set( &cache_key, &PendingResetPassword { user_uid: user.id, created_at: now, }, ) .await { tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token"); self.metrics .auth_password_reset_total .with_label_values(&["cache_error"]) .inc(); return Ok(()); } let domain = match self.config.main_domain() { Ok(d) => d, Err(e) => { tracing::error!(error = %e, "Domain not configured for password reset"); self.metrics .auth_password_reset_total .with_label_values(&["config_error"]) .inc(); return Ok(()); } }; let reset_link = format!("{}/auth/reset-password?token={}", domain, token); if let Err(e) = self .email .send(EmailMessage { to: params.email.clone(), subject: "Reset Your Password".to_string(), body: format!( "You requested to reset your GitDataAI password.\n\n\ Reset your password here:\n\n{}\n\n\ If you did not request this, ignore this email.", reset_link ), }) .await { tracing::error!(error = %e, email = %params.email, "Failed to queue password reset email"); self.metrics .auth_password_reset_total .with_label_values(&["queue_error"]) .inc(); } else { self.metrics .auth_password_reset_total .with_label_values(&["request_success"]) .inc(); } tracing::info!(email = %params.email, user_uid = %user.id, "Password reset email queued"); } Ok(()) } pub async fn auth_reset_password_verify( &self, context: &Session, params: ResetPasswordVerifyParams, ) -> Result<(), AppError> { if params.token.is_empty() { self.metrics .auth_password_reset_total .with_label_values(&["invalid_token"]) .inc(); return Err(AppError::InvalidResetToken); } let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, params.token); let pending = self .cache .get::(&cache_key) .await .map_err(|e| AppError::InternalServerError(e.to_string()))? .ok_or(AppError::InvalidResetToken)?; if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) { let _ = self.cache.remove(&cache_key).await; self.metrics .auth_password_reset_total .with_label_values(&["expired"]) .inc(); return Err(AppError::ResetTokenExpired); } self.cache .remove(&cache_key) .await .map_err(|e| AppError::InternalServerError(e.to_string()))?; let password = self.auth_rsa_decode(context, params.password).await?; Self::validate_password_strength(&password)?; let password_hash = Argon2::default() .hash_password(password.as_bytes()) .map_err(|e| AppError::PasswordHashError(e.to_string()))? .to_string(); let now = chrono::Utc::now(); let result = sqlx::query( "UPDATE user_password SET hash = $1, updated_at = $2 WHERE \"user\" = $3 AND is_active = true", ) .bind(&password_hash) .bind(now) .bind(pending.user_uid) .execute(self.db.writer()) .await .map_err(|e| AppError::DatabaseError(e.to_string()))?; if result.rows_affected() == 0 { self.metrics .auth_password_reset_total .with_label_values(&["invalid_token"]) .inc(); return Err(AppError::InvalidResetToken); } self.metrics .auth_password_reset_total .with_label_values(&["verify_success"]) .inc(); tracing::info!(user_uid = %pending.user_uid, "Password reset successfully"); Ok(()) } fn generate_reset_token() -> String { #[allow(deprecated)] let mut rng = rand::rng(); let token: String = (0..64).map(|_| rng.sample(Alphanumeric) as char).collect(); format!("rst_{}", token) } }