feat: harden auth and stream uploads
This commit is contained in:
@@ -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()
|
||||
|
||||
99
src/services/credentials.rs
Normal file
99
src/services/credentials.rs
Normal 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
34
src/services/filename.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
62
src/services/rate_limit.rs
Normal file
62
src/services/rate_limit.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user