883 lines
25 KiB
Rust
883 lines
25 KiB
Rust
use crate::api::context;
|
|
use crate::api::envelope::Envelope;
|
|
use crate::auth;
|
|
use crate::error::{AppError, ErrorCode};
|
|
use crate::services::{credentials, mail, rate_limit, settings};
|
|
use crate::state::AppState;
|
|
|
|
use axum::{
|
|
extract::{ConnectInfo, State},
|
|
http::HeaderMap,
|
|
routing::post,
|
|
Json, Router,
|
|
};
|
|
use chrono::{DateTime, Duration, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use std::net::SocketAddr;
|
|
use uuid::Uuid;
|
|
|
|
pub fn router() -> Router<AppState> {
|
|
Router::new()
|
|
.route("/register", post(register))
|
|
.route("/login", post(login))
|
|
.route("/send-verification", post(send_verification))
|
|
.route("/verify-email", post(verify_email))
|
|
.route("/forgot-password", post(forgot_password))
|
|
.route("/reset-password", post(reset_password))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct RegisterRequest {
|
|
email: String,
|
|
password: String,
|
|
username: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct RegisterResponse {
|
|
user: UserView,
|
|
token: String,
|
|
message: String,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct LoginRequest {
|
|
email: String,
|
|
password: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct LoginResponse {
|
|
token: String,
|
|
expires_at: DateTime<Utc>,
|
|
user: UserView,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct UserView {
|
|
id: Uuid,
|
|
email: String,
|
|
username: String,
|
|
role: String,
|
|
email_verified: bool,
|
|
}
|
|
|
|
#[derive(Debug, sqlx::FromRow)]
|
|
struct UserRow {
|
|
id: Uuid,
|
|
email: String,
|
|
username: String,
|
|
password_hash: String,
|
|
role: String,
|
|
is_active: bool,
|
|
email_verified_at: Option<DateTime<Utc>>,
|
|
token_version: i32,
|
|
}
|
|
|
|
async fn register(
|
|
State(state): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<RegisterRequest>,
|
|
) -> Result<Json<Envelope<RegisterResponse>>, AppError> {
|
|
let ip = context::client_ip(&headers, addr.ip());
|
|
let policy = settings::runtime_policy(&state).await?;
|
|
if !policy.features.registration_enabled {
|
|
return Err(AppError::new(
|
|
ErrorCode::Forbidden,
|
|
"用户注册功能当前已关闭",
|
|
));
|
|
}
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_register_ip",
|
|
&ip.to_string(),
|
|
policy.rate_limits.register_ip_per_hour,
|
|
60 * 60,
|
|
"注册请求过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
|
|
credentials::validate_email(&req.email)?;
|
|
credentials::validate_username(&req.username)?;
|
|
credentials::validate_password(&req.password)?;
|
|
|
|
let password_hash = credentials::hash_password(&req.password).await?;
|
|
let verification_required = policy.auth.email_verification_required;
|
|
let verified_at = (!verification_required).then(Utc::now);
|
|
|
|
let user = sqlx::query_as::<_, UserRow>(
|
|
r#"
|
|
INSERT INTO users (email, username, password_hash, email_verified_at)
|
|
VALUES ($1, $2, $3, $4)
|
|
RETURNING
|
|
id,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role::text AS role,
|
|
is_active,
|
|
email_verified_at,
|
|
token_version
|
|
"#,
|
|
)
|
|
.bind(req.email.to_lowercase())
|
|
.bind(&req.username)
|
|
.bind(password_hash)
|
|
.bind(verified_at)
|
|
.fetch_one(&state.db)
|
|
.await
|
|
.map_err(map_unique_violation)?;
|
|
|
|
let (token, _expires_at) = auth::issue_jwt(
|
|
&state.config.jwt_secret,
|
|
state.config.jwt_expiry_hours,
|
|
user.id,
|
|
&user.role,
|
|
user.token_version,
|
|
)?;
|
|
|
|
if verification_required {
|
|
let verification_token = credentials::generate_token();
|
|
let token_hash = credentials::sha256_hex(&verification_token);
|
|
let expires_at_db = Utc::now() + Duration::hours(24);
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
"#,
|
|
)
|
|
.bind(user.id)
|
|
.bind(token_hash)
|
|
.bind(expires_at_db)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err)
|
|
})?;
|
|
|
|
let verification_url = format!(
|
|
"{}/verify-email?token={}",
|
|
state.config.public_base_url, verification_token
|
|
);
|
|
|
|
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
|
})?;
|
|
}
|
|
|
|
let body = RegisterResponse {
|
|
user: UserView {
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
role: user.role,
|
|
email_verified: user.email_verified_at.is_some(),
|
|
},
|
|
token,
|
|
message: if verification_required {
|
|
"注册成功,验证邮件已发送至您的邮箱".to_string()
|
|
} else {
|
|
"注册成功".to_string()
|
|
},
|
|
};
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: body,
|
|
}))
|
|
}
|
|
|
|
async fn login(
|
|
State(state): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<LoginRequest>,
|
|
) -> Result<Json<Envelope<LoginResponse>>, AppError> {
|
|
let identity = req.email.trim();
|
|
if identity.is_empty() {
|
|
return Err(AppError::new(
|
|
ErrorCode::InvalidRequest,
|
|
"邮箱或用户名不能为空",
|
|
));
|
|
}
|
|
|
|
let ip = context::client_ip(&headers, addr.ip());
|
|
let policy = settings::runtime_policy(&state).await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_login_ip",
|
|
&ip.to_string(),
|
|
policy.rate_limits.login_ip_per_5_minutes,
|
|
5 * 60,
|
|
"登录请求过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_login_identity",
|
|
&identity.to_lowercase(),
|
|
policy.rate_limits.login_identity_per_5_minutes,
|
|
5 * 60,
|
|
"该账号登录尝试过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
|
|
let user = if identity.contains('@') {
|
|
credentials::validate_email(identity)?;
|
|
sqlx::query_as::<_, UserRow>(
|
|
r#"
|
|
SELECT
|
|
id,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role::text AS role,
|
|
is_active,
|
|
email_verified_at,
|
|
token_version
|
|
FROM users
|
|
WHERE email = $1
|
|
"#,
|
|
)
|
|
.bind(identity.to_lowercase())
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
} else {
|
|
credentials::validate_username(identity)?;
|
|
sqlx::query_as::<_, UserRow>(
|
|
r#"
|
|
SELECT
|
|
id,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role::text AS role,
|
|
is_active,
|
|
email_verified_at,
|
|
token_version
|
|
FROM users
|
|
WHERE username = $1
|
|
"#,
|
|
)
|
|
.bind(identity)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
}
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
|
|
|
let Some(user) = user else {
|
|
credentials::consume_dummy_password_work(&req.password).await?;
|
|
return Err(AppError::new(ErrorCode::Unauthorized, "账号或密码错误"));
|
|
};
|
|
|
|
if !credentials::verify_password(&req.password, &user.password_hash).await? {
|
|
return Err(AppError::new(ErrorCode::Unauthorized, "账号或密码错误"));
|
|
}
|
|
if !user.is_active {
|
|
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
|
|
}
|
|
let verification_required = policy.auth.email_verification_required;
|
|
|
|
let (token, expires_at) = auth::issue_jwt(
|
|
&state.config.jwt_secret,
|
|
state.config.jwt_expiry_hours,
|
|
user.id,
|
|
&user.role,
|
|
user.token_version,
|
|
)?;
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: LoginResponse {
|
|
token,
|
|
expires_at,
|
|
user: UserView {
|
|
id: user.id,
|
|
email: user.email,
|
|
username: user.username,
|
|
role: user.role,
|
|
email_verified: user.email_verified_at.is_some() || !verification_required,
|
|
},
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct MessageResponse {
|
|
message: String,
|
|
}
|
|
|
|
async fn send_verification(
|
|
State(state): State<AppState>,
|
|
headers: HeaderMap,
|
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
|
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
|
|
let policy = settings::runtime_policy(&state).await?;
|
|
|
|
if !policy.auth.email_verification_required {
|
|
return Ok(Json(Envelope {
|
|
success: true,
|
|
data: MessageResponse {
|
|
message: "邮箱验证功能当前已关闭,无需验证".to_string(),
|
|
},
|
|
}));
|
|
}
|
|
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_send_verification_user",
|
|
&claims.sub.to_string(),
|
|
policy.rate_limits.verification_email_per_minute,
|
|
60,
|
|
"发送过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
|
|
let user = sqlx::query_as::<_, UserRow>(
|
|
r#"
|
|
SELECT
|
|
id,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role::text AS role,
|
|
is_active,
|
|
email_verified_at,
|
|
token_version
|
|
FROM users
|
|
WHERE id = $1
|
|
"#,
|
|
)
|
|
.bind(claims.sub)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?
|
|
.ok_or_else(|| AppError::new(ErrorCode::Unauthorized, "用户不存在或未登录"))?;
|
|
|
|
if claims.ver != user.token_version {
|
|
return Err(AppError::new(
|
|
ErrorCode::Unauthorized,
|
|
"登录状态已失效,请重新登录",
|
|
));
|
|
}
|
|
|
|
if user.email_verified_at.is_some() {
|
|
return Ok(Json(Envelope {
|
|
success: true,
|
|
data: MessageResponse {
|
|
message: "邮箱已验证,无需重复验证".to_string(),
|
|
},
|
|
}));
|
|
}
|
|
|
|
let verification_token = credentials::generate_token();
|
|
let token_hash = credentials::sha256_hex(&verification_token);
|
|
let expires_at_db = Utc::now() + Duration::hours(24);
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO email_verifications (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
"#,
|
|
)
|
|
.bind(user.id)
|
|
.bind(token_hash)
|
|
.bind(expires_at_db)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "创建邮箱验证记录失败").with_source(err))?;
|
|
|
|
let verification_url = format!(
|
|
"{}/verify-email?token={}",
|
|
state.config.public_base_url, verification_token
|
|
);
|
|
|
|
mail::send_verification_email(&state, &user.email, &user.username, &verification_url)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::new(ErrorCode::MailSendFailed, "验证邮件发送失败").with_source(err)
|
|
})?;
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: MessageResponse {
|
|
message: "验证邮件已发送,请查收".to_string(),
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct VerifyEmailRequest {
|
|
token: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct VerifyEmailResponse {
|
|
message: String,
|
|
session_invalidated: bool,
|
|
}
|
|
|
|
struct EmailChangeConfirmation {
|
|
old_email: String,
|
|
}
|
|
|
|
async fn verify_email(
|
|
State(state): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<VerifyEmailRequest>,
|
|
) -> Result<Json<Envelope<VerifyEmailResponse>>, AppError> {
|
|
if req.token.trim().is_empty() {
|
|
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
|
|
}
|
|
|
|
let ip = context::client_ip(&headers, addr.ip());
|
|
let policy = settings::runtime_policy(&state).await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_verify_email_ip",
|
|
&ip.to_string(),
|
|
policy.rate_limits.email_verify_ip_per_15_minutes,
|
|
15 * 60,
|
|
"验证请求过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
|
|
let token_hash = credentials::sha256_hex(&req.token);
|
|
let now = Utc::now();
|
|
|
|
if let Some(confirmation) = confirm_email_change(&state, &token_hash, now).await? {
|
|
if let Err(err) = mail::send_email_change_notice(&state, &confirmation.old_email).await {
|
|
tracing::warn!(error = ?err, "email change notice delivery failed");
|
|
}
|
|
return Ok(Json(Envelope {
|
|
success: true,
|
|
data: VerifyEmailResponse {
|
|
message: "新邮箱确认成功,请重新登录".to_string(),
|
|
session_invalidated: true,
|
|
},
|
|
}));
|
|
}
|
|
|
|
let updated = sqlx::query(
|
|
r#"
|
|
WITH v AS (
|
|
SELECT user_id
|
|
FROM email_verifications
|
|
WHERE token_hash = $1
|
|
AND verified_at IS NULL
|
|
AND expires_at > $2
|
|
LIMIT 1
|
|
),
|
|
u AS (
|
|
UPDATE users
|
|
SET email_verified_at = $2
|
|
WHERE id = (SELECT user_id FROM v)
|
|
AND email_verified_at IS NULL
|
|
RETURNING id
|
|
)
|
|
UPDATE email_verifications
|
|
SET verified_at = $2
|
|
WHERE token_hash = $1
|
|
AND verified_at IS NULL
|
|
AND expires_at > $2
|
|
AND user_id IN (SELECT id FROM u)
|
|
"#,
|
|
)
|
|
.bind(token_hash)
|
|
.bind(now)
|
|
.execute(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "邮箱验证失败").with_source(err))?;
|
|
|
|
if updated.rows_affected() == 0 {
|
|
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
|
}
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: VerifyEmailResponse {
|
|
message: "邮箱验证成功".to_string(),
|
|
session_invalidated: false,
|
|
},
|
|
}))
|
|
}
|
|
|
|
async fn confirm_email_change(
|
|
state: &AppState,
|
|
token_hash: &str,
|
|
now: DateTime<Utc>,
|
|
) -> Result<Option<EmailChangeConfirmation>, AppError> {
|
|
let user_id: Option<Uuid> = sqlx::query_scalar(
|
|
r#"
|
|
SELECT user_id
|
|
FROM email_change_requests
|
|
WHERE token_hash = $1
|
|
AND confirmed_at IS NULL
|
|
AND canceled_at IS NULL
|
|
AND expires_at > $2
|
|
"#,
|
|
)
|
|
.bind(token_hash)
|
|
.bind(now)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询邮箱变更请求失败").with_source(err))?;
|
|
let Some(user_id) = user_id else {
|
|
return Ok(None);
|
|
};
|
|
|
|
let mut tx = state
|
|
.db
|
|
.begin()
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
|
let user: Option<(String,)> =
|
|
sqlx::query_as("SELECT email FROM users WHERE id = $1 FOR UPDATE")
|
|
.bind(user_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))?;
|
|
let Some((old_email,)) = user else {
|
|
tx.rollback().await.ok();
|
|
return Ok(None);
|
|
};
|
|
|
|
let request: Option<(Uuid, String)> = sqlx::query_as(
|
|
r#"
|
|
SELECT id, new_email
|
|
FROM email_change_requests
|
|
WHERE token_hash = $1
|
|
AND user_id = $2
|
|
AND confirmed_at IS NULL
|
|
AND canceled_at IS NULL
|
|
AND expires_at > $3
|
|
FOR UPDATE
|
|
"#,
|
|
)
|
|
.bind(token_hash)
|
|
.bind(user_id)
|
|
.bind(now)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定邮箱变更请求失败").with_source(err))?;
|
|
let Some((request_id, new_email)) = request else {
|
|
tx.rollback().await.ok();
|
|
return Ok(None);
|
|
};
|
|
|
|
let changed = sqlx::query(
|
|
r#"
|
|
UPDATE users u
|
|
SET email = $2,
|
|
email_verified_at = $3,
|
|
token_version = token_version + 1,
|
|
updated_at = NOW()
|
|
WHERE u.id = $1
|
|
AND NOT EXISTS (
|
|
SELECT 1 FROM users other
|
|
WHERE other.email = $2 AND other.id <> u.id
|
|
)
|
|
"#,
|
|
)
|
|
.bind(user_id)
|
|
.bind(&new_email)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| {
|
|
if matches!(&err, sqlx::Error::Database(db) if db.code().as_deref() == Some("23505")) {
|
|
AppError::new(ErrorCode::InvalidRequest, "新邮箱已被其他账号使用")
|
|
} else {
|
|
AppError::new(ErrorCode::Internal, "确认新邮箱失败").with_source(err)
|
|
}
|
|
})?;
|
|
if changed.rows_affected() == 0 {
|
|
return Err(AppError::new(
|
|
ErrorCode::InvalidRequest,
|
|
"新邮箱已被其他账号使用",
|
|
));
|
|
}
|
|
|
|
sqlx::query(
|
|
"UPDATE email_change_requests SET confirmed_at = $2 WHERE id = $1 AND confirmed_at IS NULL AND canceled_at IS NULL",
|
|
)
|
|
.bind(request_id)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "完成邮箱变更请求失败").with_source(err))?;
|
|
sqlx::query(
|
|
"UPDATE email_change_requests SET canceled_at = $2 WHERE user_id = $1 AND id <> $3 AND confirmed_at IS NULL AND canceled_at IS NULL",
|
|
)
|
|
.bind(user_id)
|
|
.bind(now)
|
|
.bind(request_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "撤销旧邮箱变更请求失败").with_source(err))?;
|
|
sqlx::query("UPDATE password_resets SET used_at = $2 WHERE user_id = $1 AND used_at IS NULL")
|
|
.bind(user_id)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::new(ErrorCode::Internal, "撤销密码重置请求失败").with_source(err)
|
|
})?;
|
|
sqlx::query(
|
|
"UPDATE email_verifications SET verified_at = $2 WHERE user_id = $1 AND verified_at IS NULL",
|
|
)
|
|
.bind(user_id)
|
|
.bind(now)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "撤销旧邮箱验证请求失败").with_source(err))?;
|
|
|
|
tx.commit()
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交邮箱变更失败").with_source(err))?;
|
|
Ok(Some(EmailChangeConfirmation { old_email }))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ForgotPasswordRequest {
|
|
email: String,
|
|
}
|
|
|
|
async fn forgot_password(
|
|
State(state): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ForgotPasswordRequest>,
|
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
|
credentials::validate_email(&req.email)?;
|
|
|
|
let ip = context::client_ip(&headers, addr.ip());
|
|
let policy = settings::runtime_policy(&state).await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_forgot_ip",
|
|
&ip.to_string(),
|
|
policy.rate_limits.forgot_password_ip_per_15_minutes,
|
|
15 * 60,
|
|
"找回密码请求过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_forgot_email",
|
|
&req.email.to_lowercase(),
|
|
policy.rate_limits.forgot_password_email_per_15_minutes,
|
|
15 * 60,
|
|
"找回密码请求过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
|
|
let requested_email = req.email.to_lowercase();
|
|
let mut tx = state
|
|
.db
|
|
.begin()
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
|
let user = sqlx::query_as::<_, UserRow>(
|
|
r#"
|
|
SELECT
|
|
id,
|
|
email,
|
|
username,
|
|
password_hash,
|
|
role::text AS role,
|
|
is_active,
|
|
email_verified_at,
|
|
token_version
|
|
FROM users
|
|
WHERE email = $1
|
|
FOR UPDATE
|
|
"#,
|
|
)
|
|
.bind(&requested_email)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
|
|
|
let delivery = if let Some(user) = user {
|
|
let reset_token = credentials::generate_token();
|
|
let token_hash = credentials::sha256_hex(&reset_token);
|
|
let expires_at_db = Utc::now() + Duration::hours(1);
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO password_resets (user_id, token_hash, expires_at)
|
|
VALUES ($1, $2, $3)
|
|
"#,
|
|
)
|
|
.bind(user.id)
|
|
.bind(token_hash)
|
|
.bind(expires_at_db)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| {
|
|
AppError::new(ErrorCode::Internal, "创建密码重置记录失败").with_source(err)
|
|
})?;
|
|
|
|
let reset_url = format!(
|
|
"{}/reset-password?token={}",
|
|
state.config.public_base_url, reset_token
|
|
);
|
|
Some((user.email, user.username, reset_url))
|
|
} else {
|
|
None
|
|
};
|
|
tx.commit().await.map_err(|err| {
|
|
AppError::new(ErrorCode::Internal, "提交密码找回事务失败").with_source(err)
|
|
})?;
|
|
|
|
if let Some((email, username, reset_url)) = delivery {
|
|
let _ = mail::send_password_reset_email(&state, &email, &username, &reset_url).await;
|
|
}
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: MessageResponse {
|
|
message: "如果该邮箱已注册,您将收到重置邮件".to_string(),
|
|
},
|
|
}))
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
struct ResetPasswordRequest {
|
|
token: String,
|
|
new_password: String,
|
|
}
|
|
|
|
async fn reset_password(
|
|
State(state): State<AppState>,
|
|
ConnectInfo(addr): ConnectInfo<SocketAddr>,
|
|
headers: HeaderMap,
|
|
Json(req): Json<ResetPasswordRequest>,
|
|
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
|
|
if req.token.trim().is_empty() {
|
|
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
|
|
}
|
|
credentials::validate_password(&req.new_password)?;
|
|
|
|
let ip = context::client_ip(&headers, addr.ip());
|
|
let policy = settings::runtime_policy(&state).await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_reset_ip",
|
|
&ip.to_string(),
|
|
policy.rate_limits.password_reset_ip_per_15_minutes,
|
|
15 * 60,
|
|
"重置密码请求过于频繁,请稍后再试",
|
|
)
|
|
.await?;
|
|
rate_limit::enforce(
|
|
&state,
|
|
"auth_reset_token",
|
|
&req.token,
|
|
policy.rate_limits.password_reset_token_per_15_minutes,
|
|
15 * 60,
|
|
"该重置链接尝试次数过多,请重新申请",
|
|
)
|
|
.await?;
|
|
|
|
let token_hash = credentials::sha256_hex(&req.token);
|
|
let now = Utc::now();
|
|
let reset_user_id: Option<Uuid> = sqlx::query_scalar(
|
|
r#"
|
|
SELECT user_id
|
|
FROM password_resets
|
|
WHERE token_hash = $1
|
|
AND used_at IS NULL
|
|
AND expires_at > $2
|
|
"#,
|
|
)
|
|
.bind(&token_hash)
|
|
.bind(now)
|
|
.fetch_optional(&state.db)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
|
|
let Some(reset_user_id) = reset_user_id else {
|
|
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
|
};
|
|
|
|
let password_hash = credentials::hash_password(&req.new_password).await?;
|
|
|
|
let mut tx = state
|
|
.db
|
|
.begin()
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
|
|
|
|
let locked_user: Option<Uuid> =
|
|
sqlx::query_scalar("SELECT id FROM users WHERE id = $1 FOR UPDATE")
|
|
.bind(reset_user_id)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "锁定用户失败").with_source(err))?;
|
|
if locked_user.is_none() {
|
|
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
|
}
|
|
|
|
let user_id: Option<Uuid> = sqlx::query_scalar(
|
|
r#"
|
|
SELECT user_id
|
|
FROM password_resets
|
|
WHERE token_hash = $1
|
|
AND used_at IS NULL
|
|
AND expires_at > $2
|
|
FOR UPDATE
|
|
"#,
|
|
)
|
|
.bind(&token_hash)
|
|
.bind(now)
|
|
.fetch_optional(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
|
|
|
|
let Some(user_id) = user_id else {
|
|
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
|
};
|
|
if user_id != reset_user_id {
|
|
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
|
|
}
|
|
|
|
sqlx::query(
|
|
"UPDATE users SET password_hash = $1, token_version = token_version + 1, updated_at = NOW() WHERE id = $2",
|
|
)
|
|
.bind(password_hash)
|
|
.bind(user_id)
|
|
.execute(&mut *tx)
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "更新密码失败").with_source(err))?;
|
|
|
|
credentials::invalidate_account_recovery(&mut tx, user_id, now).await?;
|
|
|
|
tx.commit()
|
|
.await
|
|
.map_err(|err| AppError::new(ErrorCode::Internal, "提交事务失败").with_source(err))?;
|
|
|
|
Ok(Json(Envelope {
|
|
success: true,
|
|
data: MessageResponse {
|
|
message: "密码重置成功".to_string(),
|
|
},
|
|
}))
|
|
}
|
|
|
|
fn map_unique_violation(err: sqlx::Error) -> AppError {
|
|
if let sqlx::Error::Database(db_err) = &err {
|
|
if let Some(code) = db_err.code() {
|
|
if code == "23505" {
|
|
return AppError::new(ErrorCode::InvalidRequest, "邮箱或用户名已存在");
|
|
}
|
|
}
|
|
}
|
|
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
|
|
}
|