Files
gitks/service/auth/reset_pass.rs
T
zhenyi 420dedbc1e feat(service): expand service layer with new domain operations
- Add IM service modules: audit, channel roles, custom emojis, forum
  tags, integrations, invitations, repo links, slash commands, stages,
  voice, webhooks
- Add PR service modules: review requests, templates
- Add repo service modules: contributors, release assets, git extras
  (archive, branch rename, commit extras, diff/merge, tag, tree)
- Add user service: social (follow/block)
- Add internal auth service
- Update existing service modules with expanded functionality
- Remove deleted IM modules: articles, delivery trace, drafts,
  follows, messages, polls, presence, reactions, threads
2026-06-10 18:49:32 +08:00

190 lines
6.9 KiB
Rust

use argon2::{Argon2, PasswordHasher, password_hash::SaltString};
use chrono::{Duration, Utc};
use serde::{Deserialize, Serialize};
use std::time::Duration as StdDuration;
use crate::error::AppError;
use crate::pb::email::{EmailAddress, SendEmailRequest};
use crate::service::AuthService;
use crate::session::Session;
#[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, utoipa::ToSchema)]
struct PendingResetPassword {
user_uid: uuid::Uuid,
created_at: chrono::DateTime<Utc>,
}
impl AuthService {
const RESET_PASS_PREFIX: &'static str = "auth:reset_pass:";
const RESET_PASS_EXPIRY_HOURS: i64 = 1;
const RESET_PASS_EXPIRY_SECS: u64 = 60 * 60;
const RESET_PASS_COOLDOWN_SECS: u64 = 60; // 60 seconds between requests
const RESET_PASS_DAILY_LIMIT: u64 = 5; // Max 5 requests per day
const RESET_PASS_DAILY_SECS: u64 = 24 * 60 * 60; // 24 hours
pub async fn auth_reset_password_request(
&self,
params: ResetPasswordRequest,
) -> Result<(), AppError> {
let email = params.email.trim().to_lowercase();
// Rate limiting: check cooldown
let cooldown_key = format!("{}cooldown:{}", Self::RESET_PASS_PREFIX, email);
if self.ctx.cache.exists(&cooldown_key).await {
tracing::warn!(email = %email, "Password reset request rate limited (cooldown)");
return Ok(()); // Don't reveal if email exists
}
// Rate limiting: check daily limit
let daily_key = format!("{}daily:{}", Self::RESET_PASS_PREFIX, email);
let daily_count: u64 = self.ctx.cache.get(&daily_key).await.unwrap_or(0);
if daily_count >= Self::RESET_PASS_DAILY_LIMIT {
tracing::warn!(email = %email, count = daily_count, "Password reset request rate limited (daily limit)");
return Ok(()); // Don't reveal if email exists
}
let user = self.auth_find_user_by_email(&email).await.ok();
if let Some(user) = user {
let token = super::generate_token("rst");
let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, token);
let now = chrono::Utc::now();
if let Err(e) = self.ctx.cache.set(
&cache_key,
&PendingResetPassword {
user_uid: user.id,
created_at: now,
},
Some(StdDuration::from_secs(Self::RESET_PASS_EXPIRY_SECS)),
).await {
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
return Ok(());
}
let domain = match self.ctx.config.main_domain() {
Ok(d) => d,
Err(e) => {
tracing::error!(error = %e, "Domain not configured for password reset");
return Ok(());
}
};
let reset_link = format!("{}/auth/reset-password?token={}", domain, token);
let mut mail = match self.ctx.registry.get_email_client() {
Some(c) => c,
None => {
tracing::error!("mail service not available");
return Ok(());
}
};
if let Err(e) = mail
.send_email(tonic::Request::new(SendEmailRequest {
to: vec![EmailAddress {
email: email.clone(),
name: String::new(),
}],
subject: "Reset Your Password".into(),
text_body: format!(
"You requested to reset your password.\n\n\
Reset your password here:\n\n{}\n\n\
If you did not request this, ignore this email.",
reset_link
),
..Default::default()
}))
.await
{
tracing::error!(error = %e, email = %email, "Failed to send password reset email");
return Ok(());
}
// Set cooldown only after successful email send
if let Err(e) = self.ctx.cache.set(
&cooldown_key,
&true,
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
).await {
tracing::warn!(error = %e, "Failed to set cooldown");
}
// Increment daily counter only after successful email send
let new_count = daily_count + 1;
if let Err(e) = self.ctx.cache.set(
&daily_key,
&new_count,
Some(StdDuration::from_secs(Self::RESET_PASS_DAILY_SECS)),
).await {
tracing::warn!(error = %e, "Failed to increment daily counter");
}
tracing::info!(email = %email, user_uid = %user.id, "Password reset email sent");
}
Ok(())
}
pub async fn auth_reset_password_verify(
&self,
context: &Session,
params: ResetPasswordVerifyParams,
) -> Result<(), AppError> {
if params.token.is_empty() {
return Err(AppError::InvalidResetToken);
}
let cache_key = format!("{}{}", Self::RESET_PASS_PREFIX, params.token);
let pending = self
.ctx
.cache
.get::<PendingResetPassword>(&cache_key)
.await
.ok_or(AppError::InvalidResetToken)?;
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
let _ = self.ctx.cache.delete(&cache_key).await;
return Err(AppError::ResetTokenExpired);
}
let password = self.auth_rsa_decode(context, params.password).await?;
crate::service::util::validate_password_strength(&password)?;
let _ = self.ctx.cache.delete(&cache_key).await;
let salt = SaltString::generate(&mut rand::thread_rng());
let password_hash = Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|e| AppError::PasswordHashError(e.to_string()))?
.to_string();
let now = chrono::Utc::now();
let result = sqlx::query(
"UPDATE user_password SET password_hash = $1, password_updated_at = $2, updated_at = $2 \
WHERE user_id = $3",
)
.bind(&password_hash)
.bind(now)
.bind(pending.user_uid)
.execute(self.ctx.db.writer())
.await
.map_err(AppError::Database)?;
if result.rows_affected() == 0 {
return Err(AppError::InvalidResetToken);
}
tracing::info!(user_uid = %pending.user_uid, "Password reset successfully");
Ok(())
}
}