feat: init
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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) {
|
||||
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).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)),
|
||||
) {
|
||||
tracing::error!(error = %e, user_uid = %user.id, "Failed to cache reset token");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Set cooldown
|
||||
if let Err(e) = self.ctx.cache.set(
|
||||
&cooldown_key,
|
||||
&true,
|
||||
Some(StdDuration::from_secs(Self::RESET_PASS_COOLDOWN_SECS)),
|
||||
) {
|
||||
tracing::warn!(error = %e, "Failed to set cooldown");
|
||||
}
|
||||
|
||||
// Increment daily counter
|
||||
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)),
|
||||
) {
|
||||
tracing::warn!(error = %e, "Failed to increment daily counter");
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
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)
|
||||
.ok_or(AppError::InvalidResetToken)?;
|
||||
|
||||
if Utc::now() - pending.created_at > Duration::hours(Self::RESET_PASS_EXPIRY_HOURS) {
|
||||
let _ = self.ctx.cache.delete(&cache_key);
|
||||
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);
|
||||
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user