main
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", &"<redacted>")
.field("password_hash", &"<redacted>")
.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<String>,
ip_addr: Option<String>,
request_id: String,
) -> AuthResult<PasswordResetResponse> {
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!(
"<p>请在 {minutes} 分钟内使用以下重置令牌设置新密码:</p><p><code>{token}</code></p><p>如果你没有申请重置密码,请忽略这封邮件。</p>"
);
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<String>,
ip_addr: Option<String>,
request_id: String,
) -> AuthResult<PasswordResetConfirmResponse> {
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<Utc>) -> 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(())
}
}