187 lines
6.1 KiB
Rust
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(¶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::<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)
|
|
}
|
|
}
|