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,7 +1,7 @@
use crate::error::{AppError, ErrorCode};
use crate::services::credentials;
use crate::state::AppState;
use argon2::{Argon2, PasswordHasher};
use chrono::Utc;
use sqlx::FromRow;
use tracing::{info, warn};
@@ -71,7 +71,7 @@ pub async fn ensure_admin_user(state: &AppState) -> Result<(), AppError> {
}
let existing = matching.pop();
let password_hash = hash_password(&admin_password)?;
let password_hash = credentials::hash_password(&admin_password).await?;
if let Some(row) = existing {
sqlx::query(
@@ -210,14 +210,6 @@ fn validate_password(password: &str) -> Result<(), AppError> {
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 env_string(key: &str) -> Option<String> {
std::env::var(key)
.ok()

View File

@@ -0,0 +1,99 @@
use crate::error::{AppError, ErrorCode};
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
use sha2::{Digest, Sha256};
pub 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(())
}
pub 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(())
}
pub 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(())
}
pub async fn hash_password(password: &str) -> Result<String, AppError> {
let password = password.to_owned();
tokio::task::spawn_blocking(move || {
let salt = argon2::password_hash::SaltString::generate(&mut rand::rngs::OsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.map(|hash| hash.to_string())
.map_err(|err| err.to_string())
})
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希任务失败").with_source(err))?
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希失败").with_source(err))
}
pub async fn verify_password(password: &str, password_hash: &str) -> Result<bool, AppError> {
let password = password.to_owned();
let password_hash = password_hash.to_owned();
tokio::task::spawn_blocking(move || {
let parsed = PasswordHash::new(&password_hash).map_err(|err| err.to_string())?;
Ok::<_, String>(
Argon2::default()
.verify_password(password.as_bytes(), &parsed)
.is_ok(),
)
})
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "密码校验任务失败").with_source(err))?
.map_err(|err| AppError::new(ErrorCode::Internal, "密码哈希格式错误").with_source(err))
}
pub fn generate_token() -> String {
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
URL_SAFE_NO_PAD.encode(bytes)
}
pub fn sha256_hex(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn password_hash_round_trip_and_rejects_wrong_password() {
let hash = hash_password("correct-horse").await.unwrap();
assert!(verify_password("correct-horse", &hash).await.unwrap());
assert!(!verify_password("wrong-password", &hash).await.unwrap());
}
#[test]
fn generated_tokens_are_url_safe_and_unique() {
let first = generate_token();
let second = generate_token();
assert_ne!(first, second);
assert!(!first.contains('='));
assert_eq!(sha256_hex(&first).len(), 64);
}
}

34
src/services/filename.rs Normal file
View File

@@ -0,0 +1,34 @@
pub fn normalize_upload_name(name: Option<&str>) -> String {
let normalized = name
.unwrap_or("upload")
.trim()
.chars()
.filter(|ch| *ch != '\0')
.map(|ch| if matches!(ch, '\r' | '\n') { '_' } else { ch })
.take(255)
.collect::<String>();
if normalized.is_empty() {
"upload".to_string()
} else {
normalized
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn upload_names_are_utf8_safe_and_database_bounded() {
let name = format!("{}\r\n.png", "".repeat(300));
let normalized = normalize_upload_name(Some(&name));
assert_eq!(normalized.chars().count(), 255);
assert!(!normalized.contains(['\r', '\n', '\0']));
}
#[test]
fn empty_upload_name_uses_fallback() {
assert_eq!(normalize_upload_name(Some(" \0 ")), "upload");
}
}

View File

@@ -1,8 +1,11 @@
pub mod billing;
pub mod bootstrap;
pub mod compress;
pub mod credentials;
pub mod filename;
pub mod idempotency;
pub mod mail;
pub mod quota;
pub mod rate_limit;
pub mod settings;
pub mod storage;

View File

@@ -0,0 +1,62 @@
use crate::error::{AppError, ErrorCode};
use crate::state::AppState;
use sha2::{Digest, Sha256};
pub async fn enforce(
state: &AppState,
namespace: &str,
discriminator: &str,
limit: u32,
window_seconds: u32,
message: &str,
) -> Result<(), AppError> {
if limit == 0 || window_seconds == 0 {
return Err(AppError::new(ErrorCode::Internal, "限速配置无效"));
}
let key = rate_limit_key(namespace, discriminator);
let mut conn = state.redis.clone();
let script = redis::Script::new(
r#"
local count = redis.call('INCR', KEYS[1])
if count == 1 then
redis.call('EXPIRE', KEYS[1], ARGV[1])
end
return count
"#,
);
let count: i64 = script
.key(key)
.arg(window_seconds)
.invoke_async(&mut conn)
.await
.map_err(|err| AppError::new(ErrorCode::Internal, "请求限速检查失败").with_source(err))?;
if count > i64::from(limit) {
return Err(AppError::new(ErrorCode::RateLimited, message));
}
Ok(())
}
fn rate_limit_key(namespace: &str, discriminator: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(discriminator.as_bytes());
let digest = hex::encode(hasher.finalize());
format!("rate:{namespace}:{}", &digest[..32])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rate_limit_keys_do_not_expose_identifiers() {
let key = rate_limit_key("login", "person@example.com");
assert!(key.starts_with("rate:login:"));
assert!(!key.contains("person"));
assert_eq!(key.len(), "rate:login:".len() + 32);
}
}

View File

@@ -148,13 +148,16 @@ fn retention_prefix(hours: i64) -> String {
}
}
pub async fn store_bytes(
pub async fn store_bytes<B>(
state: &AppState,
key: &str,
bytes: Vec<u8>,
bytes: B,
content_type: &str,
) -> Result<StoredObject, AppError> {
let bytes = Bytes::from(bytes);
) -> Result<StoredObject, AppError>
where
B: Into<Bytes>,
{
let bytes = bytes.into();
if let Some(endpoint) = active_endpoint(state).await? {
match store_bytes_s3(state, &endpoint, key, bytes.clone(), content_type).await {
Ok(stored) => return Ok(stored),