use crate::AppService; use crate::error::AppError; use argon2::password_hash::{Salt, SaltString}; use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; use models::users::{user_activity_log, user_password, user_password_reset}; use rand::RngExt; use sea_orm::*; use serde::{Deserialize, Serialize}; use session::Session; use uuid::Uuid; #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ChangePasswordParams { pub old_password: String, pub new_password: String, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ResetPasswordParams { pub email: String, } #[derive(Deserialize, Serialize, Clone, Debug, utoipa::ToSchema)] pub struct ConfirmResetPasswordParams { pub token: String, pub new_password: String, } impl AppService { pub async fn auth_change_password( &self, context: &Session, params: ChangePasswordParams, ) -> Result<(), AppError> { let user_uid = context.user().ok_or(AppError::Unauthorized)?; let old_password = self.auth_rsa_decode(context, params.old_password).await?; let new_password = self.auth_rsa_decode(context, params.new_password).await?; Self::validate_password_strength(&new_password)?; let user_password = user_password::Entity::find() .filter(user_password::Column::User.eq(user_uid)) .one(&self.db) .await .ok() .flatten() .ok_or(AppError::UserNotFound)?; let password_hash = PasswordHash::new(&user_password.password_hash).map_err(|_| AppError::UserNotFound)?; Argon2::default() .verify_password(old_password.as_bytes(), &password_hash) .map_err(|_| AppError::UserNotFound)?; let salt = SaltString::generate(&mut rsa::rand_core::OsRng::default()); let new_password_hash = Argon2::default() .hash_password(new_password.as_bytes(), Salt::from_b64(&*salt.to_string())?) .map_err(|_| AppError::UserNotFound)? .to_string(); let mut active_password: user_password::ActiveModel = user_password.into(); active_password.password_hash = Set(new_password_hash); active_password.password_salt = Set(Some(salt.to_string())); active_password .update(&self.db) .await .map_err(|_| AppError::UserNotFound)?; slog::info!(self.logs, "Password changed"; "user_uid" => %user_uid, "ip" => context.ip_address()); let _ = user_activity_log::ActiveModel { user_uid: Set(Option::from(user_uid)), action: Set("password_change".to_string()), ip_address: Set(context.ip_address()), user_agent: Set(context.user_agent()), details: Set(serde_json::json!({ "method": "change_password" })), created_at: Set(chrono::Utc::now()), ..Default::default() } .insert(&self.db) .await; Ok(()) } pub async fn auth_request_password_reset( &self, params: ResetPasswordParams, ) -> Result<(), AppError> { let user = self.utils_find_user_by_email(params.email.clone()).await?; let token = self.generate_reset_token(); let expires_at = chrono::Utc::now() + chrono::Duration::hours(1); let _ = user_password_reset::Entity::delete_many() .filter(user_password_reset::Column::UserUid.eq(user.uid)) .filter(user_password_reset::Column::Used.eq(false)) .exec(&self.db) .await; let reset_token = user_password_reset::ActiveModel { token: Set(token.clone()), user_uid: Set(user.uid), expires_at: Set(expires_at), used: Set(false), created_at: Set(chrono::Utc::now()), }; reset_token .insert(&self.db) .await .map_err(|_| AppError::UserNotFound)?; let domain = self .config .main_domain() .map_err(|_| AppError::DoMainNotSet)?; let email_address = params.email.clone(); let reset_link = format!("https://{}/auth/reset-password?token={}", domain, token); let envelope = queue::EmailEnvelope { id: Uuid::new_v4(), to: email_address.clone(), subject: "Password Reset Request".to_string(), body: format!( "Hello {},\n\n\ You have requested to reset your password. Please click the link below to reset your password:\n\n\ {}\n\n\ This link will expire in 1 hour.\n\n\ If you did not request this password reset, please ignore this email.\n\n\ Best regards,\n\ GitDataAI Team", user.username, reset_link ), created_at: chrono::Utc::now(), }; self.queue_producer .publish_email(envelope) .await .map_err(|_| AppError::UserNotFound)?; slog::info!(self.logs, "Password reset email queued"; "email" => email_address); Ok(()) } pub fn validate_password_strength(password: &str) -> Result<(), AppError> { if password.len() < 8 { return Err(AppError::UserNotFound); } let has_uppercase = password.chars().any(|c| c.is_uppercase()); let has_lowercase = password.chars().any(|c| c.is_lowercase()); let has_digit = password.chars().any(|c| c.is_numeric()); if !has_uppercase || !has_lowercase || !has_digit { return Err(AppError::UserNotFound); } Ok(()) } pub fn generate_reset_token(&self) -> String { use rand::distr::Alphanumeric; #[allow(deprecated)] let mut rng = rand::rng(); let token: String = (0..64).map(|_| rng.sample(Alphanumeric) as char).collect(); format!("rst_{}", token) } pub async fn auth_cleanup_expired_reset_tokens(&self) -> Result { let now = chrono::Local::now().naive_local(); let result = user_password_reset::Entity::delete_many() .filter(user_password_reset::Column::ExpiresAt.lt(now)) .exec(&self.db) .await .map_err(|_| AppError::UserNotFound)?; slog::info!(self.logs, "Expired password reset tokens cleaned up"; "count" => result.rows_affected); Ok(result.rows_affected) } }