feat: harden auth and stream uploads

This commit is contained in:
237899745
2026-07-25 18:42:51 +08:00
parent f389dfb567
commit f3c7a77a37
26 changed files with 1006 additions and 561 deletions

View File

@@ -1,17 +1,19 @@
use crate::api::context;
use crate::api::envelope::Envelope;
use crate::auth;
use crate::error::{AppError, ErrorCode};
use crate::services::mail;
use crate::services::settings;
use crate::services::{credentials, mail, rate_limit, settings};
use crate::state::AppState;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use axum::{extract::State, http::HeaderMap, routing::post, Json, Router};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use axum::{
extract::{ConnectInfo, State},
http::HeaderMap,
routing::post,
Json, Router,
};
use chrono::{DateTime, Duration, Utc};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::net::SocketAddr;
use uuid::Uuid;
pub fn router() -> Router<AppState> {
@@ -69,17 +71,31 @@ struct UserRow {
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> {
validate_email(&req.email)?;
validate_username(&req.username)?;
validate_password(&req.password)?;
let ip = context::client_ip(&headers, addr.ip());
rate_limit::enforce(
&state,
"auth_register_ip",
&ip.to_string(),
10,
60 * 60,
"注册请求过于频繁,请稍后再试",
)
.await?;
let password_hash = hash_password(&req.password)?;
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 = settings::email_verification_required(&state).await?;
let verified_at = (!verification_required).then(Utc::now);
@@ -94,7 +110,8 @@ async fn register(
password_hash,
role::text AS role,
is_active,
email_verified_at
email_verified_at,
token_version
"#,
)
.bind(req.email.to_lowercase())
@@ -110,11 +127,12 @@ async fn register(
state.config.jwt_expiry_hours,
user.id,
&user.role,
user.token_version,
)?;
if verification_required {
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
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(
@@ -168,6 +186,8 @@ async fn register(
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();
@@ -178,8 +198,28 @@ async fn login(
));
}
let ip = context::client_ip(&headers, addr.ip());
rate_limit::enforce(
&state,
"auth_login_ip",
&ip.to_string(),
30,
5 * 60,
"登录请求过于频繁,请稍后再试",
)
.await?;
rate_limit::enforce(
&state,
"auth_login_identity",
&identity.to_lowercase(),
10,
5 * 60,
"该账号登录尝试过于频繁,请稍后再试",
)
.await?;
let user = if identity.contains('@') {
validate_email(identity)?;
credentials::validate_email(identity)?;
sqlx::query_as::<_, UserRow>(
r#"
SELECT
@@ -189,7 +229,8 @@ async fn login(
password_hash,
role::text AS role,
is_active,
email_verified_at
email_verified_at,
token_version
FROM users
WHERE email = $1
"#,
@@ -198,7 +239,7 @@ async fn login(
.fetch_optional(&state.db)
.await
} else {
validate_username(identity)?;
credentials::validate_username(identity)?;
sqlx::query_as::<_, UserRow>(
r#"
SELECT
@@ -208,7 +249,8 @@ async fn login(
password_hash,
role::text AS role,
is_active,
email_verified_at
email_verified_at,
token_version
FROM users
WHERE username = $1
"#,
@@ -224,7 +266,9 @@ async fn login(
return Err(AppError::new(ErrorCode::Forbidden, "账号已被禁用"));
}
verify_password(&req.password, &user.password_hash)?;
if !credentials::verify_password(&req.password, &user.password_hash).await? {
return Err(AppError::new(ErrorCode::Unauthorized, "账号或密码错误"));
}
let verification_required = settings::email_verification_required(&state).await?;
let (token, expires_at) = auth::issue_jwt(
@@ -232,6 +276,7 @@ async fn login(
state.config.jwt_expiry_hours,
user.id,
&user.role,
user.token_version,
)?;
Ok(Json(Envelope {
@@ -270,32 +315,15 @@ async fn send_verification(
}));
}
// Rate limit: 1 per minute per user
let key = format!(
"rate:send_verification:{}:{}",
claims.sub,
Utc::now().format("%Y%m%d%H%M")
);
let mut redis = state.redis.clone();
let count: i64 = redis::cmd("INCR")
.arg(&key)
.query_async(&mut redis)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "Redis 限流失败").with_source(err))?;
if count == 1 {
let _: () = redis::cmd("EXPIRE")
.arg(&key)
.arg(60)
.query_async(&mut redis)
.await
.unwrap_or(());
}
if count > 1 {
return Err(AppError::new(
ErrorCode::RateLimited,
"发送过于频繁,请稍后再试",
));
}
rate_limit::enforce(
&state,
"auth_send_verification_user",
&claims.sub.to_string(),
1,
60,
"发送过于频繁,请稍后再试",
)
.await?;
let user = sqlx::query_as::<_, UserRow>(
r#"
@@ -306,7 +334,8 @@ async fn send_verification(
password_hash,
role::text AS role,
is_active,
email_verified_at
email_verified_at,
token_version
FROM users
WHERE id = $1
"#,
@@ -317,6 +346,13 @@ async fn send_verification(
.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,
@@ -326,8 +362,8 @@ async fn send_verification(
}));
}
let verification_token = generate_token();
let token_hash = sha256_hex(&verification_token);
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(
@@ -369,13 +405,26 @@ struct VerifyEmailRequest {
async fn verify_email(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<VerifyEmailRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
if req.token.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "token 不能为空"));
}
let token_hash = sha256_hex(&req.token);
let ip = context::client_ip(&headers, addr.ip());
rate_limit::enforce(
&state,
"auth_verify_email_ip",
&ip.to_string(),
20,
15 * 60,
"验证请求过于频繁,请稍后再试",
)
.await?;
let token_hash = credentials::sha256_hex(&req.token);
let now = Utc::now();
let updated = sqlx::query(
@@ -428,9 +477,31 @@ struct ForgotPasswordRequest {
async fn forgot_password(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<ForgotPasswordRequest>,
) -> Result<Json<Envelope<MessageResponse>>, AppError> {
validate_email(&req.email)?;
credentials::validate_email(&req.email)?;
let ip = context::client_ip(&headers, addr.ip());
rate_limit::enforce(
&state,
"auth_forgot_ip",
&ip.to_string(),
5,
15 * 60,
"找回密码请求过于频繁,请稍后再试",
)
.await?;
rate_limit::enforce(
&state,
"auth_forgot_email",
&req.email.to_lowercase(),
3,
15 * 60,
"找回密码请求过于频繁,请稍后再试",
)
.await?;
let user = sqlx::query_as::<_, UserRow>(
r#"
@@ -441,7 +512,8 @@ async fn forgot_password(
password_hash,
role::text AS role,
is_active,
email_verified_at
email_verified_at,
token_version
FROM users
WHERE email = $1
"#,
@@ -452,8 +524,8 @@ async fn forgot_password(
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
if let Some(user) = user {
let reset_token = generate_token();
let token_hash = sha256_hex(&reset_token);
let reset_token = credentials::generate_token();
let token_hash = credentials::sha256_hex(&reset_token);
let expires_at_db = Utc::now() + Duration::hours(1);
let _ = sqlx::query(
@@ -493,15 +565,64 @@ struct ResetPasswordRequest {
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 不能为空"));
}
validate_password(&req.new_password)?;
credentials::validate_password(&req.new_password)?;
let token_hash = sha256_hex(&req.token);
let ip = context::client_ip(&headers, addr.ip());
rate_limit::enforce(
&state,
"auth_reset_ip",
&ip.to_string(),
10,
15 * 60,
"重置密码请求过于频繁,请稍后再试",
)
.await?;
rate_limit::enforce(
&state,
"auth_reset_token",
&req.token,
5,
15 * 60,
"该重置链接尝试次数过多,请重新申请",
)
.await?;
let token_hash = credentials::sha256_hex(&req.token);
let now = Utc::now();
let token_exists: bool = sqlx::query_scalar(
r#"
SELECT EXISTS(
SELECT 1
FROM password_resets
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > $2
)
"#,
)
.bind(&token_hash)
.bind(now)
.fetch_one(&state.db)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
if !token_exists {
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 user_id: Option<Uuid> = sqlx::query_scalar(
r#"
@@ -510,11 +631,12 @@ async fn reset_password(
WHERE token_hash = $1
AND used_at IS NULL
AND expires_at > $2
FOR UPDATE
"#,
)
.bind(&token_hash)
.bind(now)
.fetch_optional(&state.db)
.fetch_optional(&mut *tx)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "重置密码失败").with_source(err))?;
@@ -522,15 +644,9 @@ async fn reset_password(
return Err(AppError::new(ErrorCode::InvalidToken, "Token 无效或已过期"));
};
let password_hash = hash_password(&req.new_password)?;
let mut tx = state
.db
.begin()
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "开启事务失败").with_source(err))?;
sqlx::query("UPDATE users SET password_hash = $1 WHERE id = $2")
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)
@@ -558,66 +674,6 @@ async fn reset_password(
}))
}
fn validate_email(email: &str) -> Result<(), AppError> {
if email.trim().is_empty() || !email.contains('@') {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱格式不正确"));
}
if email.len() > 255 {
return Err(AppError::new(ErrorCode::InvalidRequest, "邮箱过长"));
}
Ok(())
}
fn validate_username(username: &str) -> Result<(), AppError> {
if username.trim().is_empty() {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名不能为空"));
}
if username.len() > 50 {
return Err(AppError::new(ErrorCode::InvalidRequest, "用户名过长"));
}
Ok(())
}
fn validate_password(password: &str) -> Result<(), AppError> {
if password.len() < 8 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码至少 8 位"));
}
if password.len() > 128 {
return Err(AppError::new(ErrorCode::InvalidRequest, "密码过长"));
}
Ok(())
}
fn hash_password(password: &str) -> Result<String, AppError> {
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?
.to_string()
.pipe(Ok)
}
fn verify_password(password: &str, password_hash: &str) -> Result<(), AppError> {
let parsed = PasswordHash::new(password_hash)
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))?;
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.map_err(|_| AppError::new(ErrorCode::Unauthorized, "账号或密码错误"))?;
Ok(())
}
fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
fn sha256_hex(token: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(token.as_bytes());
hex::encode(hasher.finalize())
}
fn map_unique_violation(err: sqlx::Error) -> AppError {
if let sqlx::Error::Database(db_err) = &err {
if let Some(code) = db_err.code() {
@@ -628,11 +684,3 @@ fn map_unique_violation(err: sqlx::Error) -> AppError {
}
AppError::new(ErrorCode::Internal, "数据库操作失败").with_source(err)
}
trait Pipe: Sized {
fn pipe<T>(self, f: impl FnOnce(Self) -> T) -> T {
f(self)
}
}
impl<T> Pipe for T {}