use crate::AuthService; use crate::errors::{AuthError, AuthResult}; use crate::events::{Labels, label}; use crate::handlers::challenge::PowProofRequest; use crate::models::users::user_events::UserEventType; use crate::models::users::user_verification_tokens::{self, VerificationPurpose}; use crate::models::users::users; use crate::session::Session; use crate::share::codes::{consume_email_code, store_email_code}; use crate::share::crypto::{random_b64url, sha256_hex}; use crate::share::password::{hash as hash_password, validate_new_password}; use crate::share::{rate, validate}; use chrono::{DateTime, Duration, Utc}; use sea_orm::entity::Set; use sea_orm::sea_query::{Expr, ExprTrait, Func}; use sea_orm::{ActiveModelTrait, ColumnTrait, EntityTrait, QueryFilter}; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct PasswordResetRequest { pub email: String, pub pow_payload: PowProofRequest, } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct PasswordResetResponse { pub expires_in_secs: u64, } #[derive(Clone, Deserialize, Serialize, ToSchema)] pub struct PasswordResetConfirmRequest { pub token: String, pub password_hash: String, pub pow_payload: PowProofRequest, } impl std::fmt::Debug for PasswordResetConfirmRequest { fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { formatter .debug_struct("PasswordResetConfirmRequest") .field("token", &"") .field("password_hash", &"") .field("pow_payload", &self.pow_payload) .finish() } } #[derive(Clone, Debug, Deserialize, Serialize, ToSchema)] pub struct PasswordResetConfirmResponse { pub user_id: i64, } const RESET_CODE_KEY_PREFIX: &str = "auth:code:reset:"; const RESET_RATE_KEY_PREFIX: &str = "auth:rate:reset:"; const RESET_HASH_KEY_ID: &str = "sha256-v1"; const PASSWORD_DIGEST_MAX_LEN: usize = 255; impl AuthService { #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))] pub async fn request_password_reset( &self, session: Session, request: PasswordResetRequest, user_agent: Option, ip_addr: Option, request_id: String, ) -> AuthResult { self.challenge_verify_pow( session.clone(), &request.pow_payload, request.email.as_bytes(), request_id.clone(), ) .await?; let email = validate::validate_email(&request.email)?; rate::rate_limit_once( self.cache(), &format!("{RESET_RATE_KEY_PREFIX}{email}"), self.auth().email_code_rate_limit_secs, Some(request_id.as_str()), ) .await?; let ttl = self.auth().reset_token_ttl_secs; let user = users::Entity::find() .filter(Expr::expr(Func::lower(Expr::col(users::Column::Email))).eq(email.clone())) .one(self.db().read()) .await?; let Some(user) = user else { return Ok(PasswordResetResponse { expires_in_secs: ttl, }); }; if user.deleted_at.is_some() || !user.allow_use { return Ok(PasswordResetResponse { expires_in_secs: ttl, }); } let now = Utc::now(); self.revoke_pending_resets(user.id, now).await?; let token = random_b64url(self.auth().reset_token_bytes); let token_hash = sha256_hex(token.as_bytes()); user_verification_tokens::ActiveModel { user_id: Set(user.id), purpose: Set(VerificationPurpose::PasswordReset), token_hash: Set(token_hash.clone()), hash_key_id: Set(RESET_HASH_KEY_ID.to_owned()), target_email: Set(Some(user.email.clone())), security_version: Set(user.security_version), created_at: Set(now), expires_at: Set(now + Duration::seconds(ttl as i64)), ..Default::default() } .insert(self.db().write()) .await?; store_email_code( self.cache(), &format!("{RESET_CODE_KEY_PREFIX}{email}"), &token_hash, ttl, Some(request_id.as_str()), ) .await?; let minutes = ttl / 60; let subject = "重置你的密码"; let text = format!( "请在 {minutes} 分钟内使用以下重置令牌设置新密码:{token}。如果你没有申请重置密码,请忽略这封邮件。" ); let html = format!( "

请在 {minutes} 分钟内使用以下重置令牌设置新密码:

{token}

如果你没有申请重置密码,请忽略这封邮件。

" ); self.publish_mail(&email, subject, &text, &html, Some(request_id.as_str())) .await?; crate::share::audit::entry( self.db(), user.id, Some(user.id), UserEventType::PasswordResetRequested, Some(serde_json::json!({"email": user.email.clone()})), Some(&request_id), ip_addr, user_agent, ) .await?; let response = PasswordResetResponse { expires_in_secs: ttl, }; self.emit_labeled( label::AUTH_PASSWORD_CREATE, Labels::new() .with(label::USER_ID, user.id) .with(label::REQUEST_ID, &request_id), &response, Some(request_id.as_str()), ) .await; Ok(response) } #[tracing::instrument(skip_all, fields(request_id = %request_id, client_ip = ?session.client_ip().ok().flatten()))] pub async fn reset_password( &self, session: Session, request: PasswordResetConfirmRequest, user_agent: Option, ip_addr: Option, request_id: String, ) -> AuthResult { self.challenge_verify_pow( session.clone(), &request.pow_payload, request.token.as_bytes(), request_id.clone(), ) .await?; if request.password_hash.is_empty() || request.password_hash.len() > PASSWORD_DIGEST_MAX_LEN { return Err(AuthError::InvalidPassword); } validate_new_password(&request.password_hash, &self.auth().password_policy)?; let now = Utc::now(); let token_hash = sha256_hex(request.token.as_bytes()); let record = user_verification_tokens::Entity::find() .filter(user_verification_tokens::Column::TokenHash.eq(token_hash.clone())) .filter( user_verification_tokens::Column::Purpose.eq(VerificationPurpose::PasswordReset), ) .one(self.db().read()) .await? .ok_or(AuthError::InvalidResetToken)?; if record.consumed_at.is_some() || record.revoked_at.is_some() || record.expires_at <= now { return Err(AuthError::InvalidResetToken); } let Some(target_email) = record.target_email.clone() else { return Err(AuthError::InvalidResetToken); }; consume_email_code( self.cache(), &format!("{RESET_CODE_KEY_PREFIX}{target_email}"), &token_hash, self.auth().max_verify_attempts as i64, Some(request_id.as_str()), ) .await?; let user = users::Entity::find_by_id(record.user_id) .one(self.db().read()) .await? .ok_or(AuthError::InvalidResetToken)?; if user.deleted_at.is_some() || !user.allow_use || user.security_version != record.security_version { return Err(AuthError::InvalidResetToken); } let consumed = user_verification_tokens::Entity::update_many() .col_expr( user_verification_tokens::Column::ConsumedAt, Expr::value(now), ) .filter(user_verification_tokens::Column::Id.eq(record.id)) .filter(user_verification_tokens::Column::ConsumedAt.is_null()) .exec(self.db().write()) .await?; if consumed.rows_affected != 1 { return Err(AuthError::InvalidResetToken); } let password_hash = hash_password(request.password_hash.clone(), Some(request_id.as_str())).await?; let updated = users::Entity::update_many() .col_expr(users::Column::PasswordHash, Expr::value(password_hash)) .col_expr(users::Column::PasswordUpdatedAt, Expr::value(now)) .col_expr(users::Column::PasswordMustChange, Expr::value(false)) .col_expr( users::Column::SecurityVersion, Expr::value(user.security_version + 1), ) .col_expr(users::Column::UpdatedAt, Expr::value(now)) .filter(users::Column::Id.eq(user.id)) .filter(users::Column::SecurityVersion.eq(user.security_version)) .exec(self.db().write()) .await?; if updated.rows_affected != 1 { return Err(AuthError::InvalidResetToken); } self.revoke_pending_resets(user.id, now).await?; crate::share::audit::entry( self.db(), user.id, Some(user.id), UserEventType::PasswordChange, Some(serde_json::json!({"source": "password_reset"})), Some(&request_id), ip_addr, user_agent, ) .await?; let response = PasswordResetConfirmResponse { user_id: user.id }; self.emit_labeled( label::AUTH_PASSWORD_UPDATE, Labels::new() .with(label::USER_ID, response.user_id) .with(label::REQUEST_ID, &request_id), &response, Some(request_id.as_str()), ) .await; Ok(response) } async fn revoke_pending_resets(&self, user_id: i64, now: DateTime) -> AuthResult<()> { user_verification_tokens::Entity::update_many() .col_expr( user_verification_tokens::Column::RevokedAt, Expr::value(now), ) .filter(user_verification_tokens::Column::UserId.eq(user_id)) .filter( user_verification_tokens::Column::Purpose.eq(VerificationPurpose::PasswordReset), ) .filter(user_verification_tokens::Column::ConsumedAt.is_null()) .filter(user_verification_tokens::Column::RevokedAt.is_null()) .exec(self.db().write()) .await?; Ok(()) } }