feat: harden auth and stream uploads
This commit is contained in:
122
src/api/user.rs
122
src/api/user.rs
@@ -2,11 +2,9 @@ use crate::api::context;
|
||||
use crate::api::envelope::Envelope;
|
||||
use crate::error::{AppError, ErrorCode};
|
||||
use crate::services::billing;
|
||||
use crate::services::mail;
|
||||
use crate::services::settings;
|
||||
use crate::services::{credentials, mail, settings};
|
||||
use crate::state::AppState;
|
||||
|
||||
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
|
||||
use axum::extract::{ConnectInfo, Path, Query, State};
|
||||
use axum::http::HeaderMap;
|
||||
use axum::routing::{delete, get, post, put};
|
||||
@@ -15,8 +13,8 @@ use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use chrono::{DateTime, Duration, Utc};
|
||||
use rand::RngCore;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use sqlx::FromRow;
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use uuid::Uuid;
|
||||
|
||||
@@ -172,7 +170,7 @@ async fn update_profile(
|
||||
|
||||
if let Some(email) = req.email.as_ref() {
|
||||
let email = email.trim().to_lowercase();
|
||||
validate_email(&email)?;
|
||||
credentials::validate_email(&email)?;
|
||||
if email != user.email {
|
||||
next_email = email;
|
||||
email_changed = true;
|
||||
@@ -181,7 +179,7 @@ async fn update_profile(
|
||||
|
||||
if let Some(username) = req.username.as_ref() {
|
||||
let username = username.trim().to_string();
|
||||
validate_username(&username)?;
|
||||
credentials::validate_username(&username)?;
|
||||
if username != user.username {
|
||||
next_username = username;
|
||||
}
|
||||
@@ -238,8 +236,8 @@ async fn update_profile(
|
||||
|
||||
let mut verification_link: Option<String> = None;
|
||||
if email_changed && verification_required {
|
||||
let token = generate_token();
|
||||
let token_hash = sha256_hex(&token);
|
||||
let token = credentials::generate_token();
|
||||
let token_hash = credentials::sha256_hex(&token);
|
||||
let expires_at = Utc::now() + Duration::hours(24);
|
||||
|
||||
sqlx::query(
|
||||
@@ -317,7 +315,7 @@ async fn update_password(
|
||||
_ => return Err(AppError::new(ErrorCode::Unauthorized, "未登录")),
|
||||
};
|
||||
|
||||
validate_password(&req.new_password)?;
|
||||
credentials::validate_password(&req.new_password)?;
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct PasswordRow {
|
||||
@@ -330,10 +328,14 @@ async fn update_password(
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询用户失败").with_source(err))?;
|
||||
|
||||
verify_password(&req.current_password, &row.password_hash)?;
|
||||
if !credentials::verify_password(&req.current_password, &row.password_hash).await? {
|
||||
return Err(AppError::new(ErrorCode::Unauthorized, "密码错误"));
|
||||
}
|
||||
|
||||
let new_hash = hash_password(&req.new_password)?;
|
||||
sqlx::query("UPDATE users SET password_hash = $2, updated_at = NOW() WHERE id = $1")
|
||||
let new_hash = credentials::hash_password(&req.new_password).await?;
|
||||
sqlx::query(
|
||||
"UPDATE users SET password_hash = $2, token_version = token_version + 1, updated_at = NOW() WHERE id = $1",
|
||||
)
|
||||
.bind(user_id)
|
||||
.bind(new_hash)
|
||||
.execute(&state.db)
|
||||
@@ -498,6 +500,7 @@ async fn list_history(
|
||||
|
||||
#[derive(Debug, FromRow)]
|
||||
struct FileRow {
|
||||
task_id: Uuid,
|
||||
id: Uuid,
|
||||
original_name: String,
|
||||
original_size: i64,
|
||||
@@ -510,11 +513,14 @@ async fn list_history(
|
||||
}
|
||||
|
||||
let now = Utc::now();
|
||||
let mut result_tasks = Vec::with_capacity(tasks.len());
|
||||
for task in tasks {
|
||||
let files: Vec<FileRow> = sqlx::query_as::<_, FileRow>(
|
||||
let task_ids = tasks.iter().map(|task| task.id).collect::<Vec<_>>();
|
||||
let files = if task_ids.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
sqlx::query_as::<_, FileRow>(
|
||||
r#"
|
||||
SELECT
|
||||
task_id,
|
||||
id,
|
||||
original_name,
|
||||
original_size,
|
||||
@@ -525,16 +531,25 @@ async fn list_history(
|
||||
error_message,
|
||||
COALESCE(storage_key, storage_path) IS NOT NULL AS has_storage
|
||||
FROM task_files
|
||||
WHERE task_id = $1
|
||||
ORDER BY created_at ASC
|
||||
WHERE task_id = ANY($1)
|
||||
ORDER BY task_id, created_at ASC
|
||||
"#,
|
||||
)
|
||||
.bind(task.id)
|
||||
.bind(&task_ids)
|
||||
.fetch_all(&state.db)
|
||||
.await
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?;
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "查询任务文件失败").with_source(err))?
|
||||
};
|
||||
let mut files_by_task = HashMap::<Uuid, Vec<FileRow>>::new();
|
||||
for file in files {
|
||||
files_by_task.entry(file.task_id).or_default().push(file);
|
||||
}
|
||||
|
||||
let file_views = files
|
||||
let mut result_tasks = Vec::with_capacity(tasks.len());
|
||||
for task in tasks {
|
||||
let file_views = files_by_task
|
||||
.remove(&task.id)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|file| HistoryFileView {
|
||||
file_id: file.id,
|
||||
@@ -837,13 +852,7 @@ fn generate_api_key() -> (String, String) {
|
||||
}
|
||||
|
||||
fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value, AppError> {
|
||||
let allowed = [
|
||||
"compress",
|
||||
"batch_compress",
|
||||
"read_stats",
|
||||
"billing_read",
|
||||
"webhook_manage",
|
||||
];
|
||||
let allowed = ["compress", "batch_compress"];
|
||||
|
||||
let mut perms = Vec::<String>::new();
|
||||
if let Some(values) = input {
|
||||
@@ -871,65 +880,6 @@ fn normalize_permissions(input: Option<Vec<String>>) -> Result<serde_json::Value
|
||||
Ok(serde_json::json!(perms))
|
||||
}
|
||||
|
||||
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);
|
||||
let hashed = Argon2::default()
|
||||
.hash_password(password.as_bytes(), &salt)
|
||||
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))?;
|
||||
Ok(hashed.to_string())
|
||||
}
|
||||
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user