gitdataai/lib/service/auth/reset_pass.rs
2026-06-01 22:04:38 +08:00

187 lines
6.1 KiB
Rust

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<Utc>,
}
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(&params.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::<PendingResetPassword>(&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)
}
}