gitdataai/libs/service/auth/password.rs
2026-04-14 19:02:01 +08:00

189 lines
6.4 KiB
Rust

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<u64, AppError> {
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)
}
}