feat: add configurable verification and redemption codes

This commit is contained in:
237899745
2026-07-25 13:57:39 +08:00
parent d1f093685d
commit c6e96464d3
32 changed files with 2108 additions and 261 deletions

View File

@@ -2,6 +2,7 @@ use crate::api::envelope::Envelope;
use crate::auth;
use crate::error::{AppError, ErrorCode};
use crate::services::mail;
use crate::services::settings;
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
@@ -79,11 +80,13 @@ async fn register(
validate_password(&req.password)?;
let password_hash = hash_password(&req.password)?;
let verification_required = settings::email_verification_required(&state).await?;
let verified_at = (!verification_required).then(Utc::now);
let user = sqlx::query_as::<_, UserRow>(
r#"
INSERT INTO users (email, username, password_hash)
VALUES ($1, $2, $3)
INSERT INTO users (email, username, password_hash, email_verified_at)
VALUES ($1, $2, $3, $4)
RETURNING
id,
email,
@@ -97,6 +100,7 @@ async fn register(
.bind(req.email.to_lowercase())
.bind(&req.username)
.bind(password_hash)
.bind(verified_at)
.fetch_one(&state.db)
.await
.map_err(map_unique_violation)?;
@@ -108,34 +112,38 @@ async fn register(
&user.role,
)?;
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
let expires_at_db = Utc::now() + Duration::hours(24);
if verification_required {
let verification_token = generate_token();
let token_hash = 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)
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::MailSendFailed, "验证邮件发送失败").with_source(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,
@@ -145,7 +153,11 @@ async fn register(
email_verified: user.email_verified_at.is_some(),
},
token,
message: "注册成功,验证邮件已发送至您的邮箱".to_string(),
message: if verification_required {
"注册成功,验证邮件已发送至您的邮箱".to_string()
} else {
"注册成功".to_string()
},
};
Ok(Json(Envelope {
@@ -213,6 +225,7 @@ async fn login(
}
verify_password(&req.password, &user.password_hash)?;
let verification_required = settings::email_verification_required(&state).await?;
let (token, expires_at) = auth::issue_jwt(
&state.config.jwt_secret,
@@ -231,7 +244,7 @@ async fn login(
email: user.email,
username: user.username,
role: user.role,
email_verified: user.email_verified_at.is_some(),
email_verified: user.email_verified_at.is_some() || !verification_required,
},
},
}))
@@ -248,6 +261,15 @@ async fn send_verification(
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
let claims = auth::require_jwt(&state.config.jwt_secret, &headers)?;
if !settings::email_verification_required(&state).await? {
return Ok(Json(Envelope {
success: true,
data: MessageResponse {
message: "邮箱验证功能当前已关闭,无需验证".to_string(),
},
}));
}
// Rate limit: 1 per minute per user
let key = format!(
"rate:send_verification:{}:{}",