feat: 完善生产级 Rust Web 模板
This commit is contained in:
@@ -1,7 +1,15 @@
|
||||
use crate::AppState;
|
||||
use crate::{
|
||||
error::ErrorResponse,
|
||||
infra::middleware::{Language, UserId},
|
||||
repositories::user_repository::UserRepository,
|
||||
utils::{
|
||||
i18n::{message, ZH_CN},
|
||||
jwt::TokenType,
|
||||
},
|
||||
AppState,
|
||||
};
|
||||
use axum::{
|
||||
extract::{Request, State},
|
||||
http::{HeaderMap, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
@@ -10,42 +18,76 @@ use serde::Deserialize;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // user_id
|
||||
pub sub: String,
|
||||
#[allow(dead_code)]
|
||||
pub exp: usize,
|
||||
pub token_type: TokenType,
|
||||
}
|
||||
|
||||
/// JWT 认证中间件
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
headers: HeaderMap,
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, StatusCode> {
|
||||
// 1. 提取 Authorization header
|
||||
let auth_header = headers
|
||||
) -> Result<Response, ErrorResponse> {
|
||||
let language = req
|
||||
.extensions()
|
||||
.get::<Language>()
|
||||
.map(|v| v.0.as_str())
|
||||
.unwrap_or(ZH_CN);
|
||||
let header = req
|
||||
.headers()
|
||||
.get("Authorization")
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
if !auth_header.starts_with("Bearer ") {
|
||||
return Err(StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
|
||||
let token = &auth_header[7..];
|
||||
|
||||
// 2. 验证 JWT
|
||||
let jwt_secret = &state.config.auth.jwt_secret;
|
||||
|
||||
let token_data = decode::<Claims>(
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.ok_or_else(|| {
|
||||
ErrorResponse::unauthorized(message(
|
||||
language,
|
||||
"缺少认证请求头",
|
||||
"Missing authorization header",
|
||||
))
|
||||
})?;
|
||||
let token = header
|
||||
.strip_prefix("Bearer ")
|
||||
.filter(|v| !v.is_empty())
|
||||
.ok_or_else(|| {
|
||||
ErrorResponse::unauthorized(message(
|
||||
language,
|
||||
"认证格式无效",
|
||||
"Invalid authorization format",
|
||||
))
|
||||
})?;
|
||||
let claims = decode::<Claims>(
|
||||
token,
|
||||
&DecodingKey::from_secret(jwt_secret.as_ref()),
|
||||
&DecodingKey::from_secret(state.config.auth.jwt_secret.as_bytes()),
|
||||
&Validation::default(),
|
||||
)
|
||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||
|
||||
// 3. 将 user_id 添加到请求扩展
|
||||
req.extensions_mut().insert(token_data.claims.sub);
|
||||
|
||||
.map_err(|_| {
|
||||
ErrorResponse::unauthorized(message(
|
||||
language,
|
||||
"令牌无效或已过期",
|
||||
"Token is invalid or expired",
|
||||
))
|
||||
})?
|
||||
.claims;
|
||||
if claims.token_type != TokenType::Access {
|
||||
return Err(ErrorResponse::unauthorized(message(
|
||||
language,
|
||||
"令牌类型无效",
|
||||
"Invalid token type",
|
||||
)));
|
||||
}
|
||||
let user = UserRepository::new(state.pool.clone())
|
||||
.find_by_id_raw(&claims.sub)
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ErrorResponse::internal(message(language, "验证用户失败", "Failed to verify user"))
|
||||
})?;
|
||||
if user.map(|v| v.deleted_at.is_some()).unwrap_or(true) {
|
||||
return Err(ErrorResponse::unauthorized(message(
|
||||
language,
|
||||
"用户不存在或已删除",
|
||||
"User not found or deleted",
|
||||
)));
|
||||
}
|
||||
req.extensions_mut().insert(UserId(claims.sub));
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
use crate::error::ErrorResponse;
|
||||
use axum::{
|
||||
body::HttpBody,
|
||||
extract::{Request, State},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
pub async fn enforce_body_limit(State(limit): State<usize>, req: Request, next: Next) -> Response {
|
||||
if req
|
||||
.body()
|
||||
.size_hint()
|
||||
.upper()
|
||||
.is_some_and(|size| size > limit as u64)
|
||||
{
|
||||
return ErrorResponse::payload_too_large("request body too large").into_response();
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::utils::i18n::{EN, ZH_CN};
|
||||
use async_trait::async_trait;
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Request},
|
||||
http::{request::Parts, StatusCode},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use std::ops::Deref;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Language(pub String);
|
||||
impl Deref for Language {
|
||||
type Target = String;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Send + Sync> FromRequestParts<S> for Language {
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, Self::Rejection> {
|
||||
parts.extensions.get::<Language>().cloned().ok_or((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"language context missing",
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn language_middleware(mut req: Request, next: Next) -> Response {
|
||||
let language = req
|
||||
.headers()
|
||||
.get("Accept-Language")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(str::trim)
|
||||
.filter(|v| *v == ZH_CN || *v == EN)
|
||||
.unwrap_or(ZH_CN)
|
||||
.to_string();
|
||||
req.extensions_mut().insert(Language(language));
|
||||
next.run(req).await
|
||||
}
|
||||
@@ -1,71 +1,68 @@
|
||||
use axum::{extract::Request, response::Response};
|
||||
use axum::{
|
||||
body::{to_bytes, Body, Bytes},
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use std::time::Instant;
|
||||
|
||||
/// Request ID 标记
|
||||
#[derive(Clone)]
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RequestId(pub String);
|
||||
|
||||
/// 请求日志中间件
|
||||
pub async fn request_logging_middleware(
|
||||
mut req: Request,
|
||||
next: axum::middleware::Next,
|
||||
) -> Response {
|
||||
let start = Instant::now();
|
||||
|
||||
// 提取请求信息
|
||||
let method = req.method().clone();
|
||||
let path = req.uri().path().to_string();
|
||||
let query = req.uri().query().map(|s| s.to_string());
|
||||
|
||||
// 生成请求 ID
|
||||
let request_id = uuid::Uuid::new_v4().to_string();
|
||||
|
||||
// 将 request_id 存储到请求扩展中
|
||||
req.extensions_mut().insert(RequestId(request_id.clone()));
|
||||
|
||||
// 第1条日志:请求开始
|
||||
let separator = "=".repeat(80);
|
||||
let header = format!("{} {}", method, path);
|
||||
|
||||
tracing::info!("{}", separator);
|
||||
tracing::info!("{}", header);
|
||||
tracing::info!("{}", separator);
|
||||
|
||||
let now_beijing = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
|
||||
let query_str = query.as_deref().unwrap_or("无");
|
||||
tracing::info!(
|
||||
"[{}] 📥 查询参数: {} | 时间: {}",
|
||||
request_id,
|
||||
query_str,
|
||||
now_beijing
|
||||
);
|
||||
|
||||
// 调用下一个处理器
|
||||
let response = next.run(req).await;
|
||||
|
||||
// 第3条日志:请求完成
|
||||
let duration = start.elapsed();
|
||||
let status = response.status();
|
||||
tracing::info!(
|
||||
"[{}] ✅ 状态码: {} | 耗时: {}ms",
|
||||
request_id,
|
||||
status.as_u16(),
|
||||
duration.as_millis()
|
||||
);
|
||||
|
||||
tracing::info!("{}", separator);
|
||||
|
||||
response
|
||||
}
|
||||
|
||||
/// 请求日志辅助工具
|
||||
pub fn log_info<T: std::fmt::Debug>(request_id: &RequestId, label: &str, data: T) {
|
||||
let data_str = format!("{:?}", data);
|
||||
let truncated = if data_str.len() > 300 {
|
||||
format!("{}...", &data_str[..300])
|
||||
pub fn truncate_string(value: &str, max: usize) -> String {
|
||||
if value.chars().count() > max {
|
||||
value.chars().take(max).collect::<String>() + "....."
|
||||
} else {
|
||||
data_str
|
||||
};
|
||||
|
||||
tracing::info!("[{}] 🔧 {} | {}", request_id.0, label, truncated);
|
||||
value.to_string()
|
||||
}
|
||||
}
|
||||
fn truncate_json(value: &mut serde_json::Value, max: usize) {
|
||||
match value {
|
||||
serde_json::Value::String(v) => *v = truncate_string(v, max),
|
||||
serde_json::Value::Array(v) => v.iter_mut().for_each(|v| truncate_json(v, max)),
|
||||
serde_json::Value::Object(v) => v.values_mut().for_each(|v| truncate_json(v, max)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
fn pretty(bytes: &Bytes) -> String {
|
||||
let raw = String::from_utf8_lossy(bytes);
|
||||
match serde_json::from_str::<serde_json::Value>(&raw) {
|
||||
Ok(mut value) => {
|
||||
truncate_json(&mut value, 50);
|
||||
serde_json::to_string_pretty(&value).unwrap_or_else(|_| raw.into_owned())
|
||||
}
|
||||
Err(_) => truncate_string(&raw, 50),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn request_logging_middleware(mut req: Request<Body>, next: Next) -> Response {
|
||||
let started = Instant::now();
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
let method = req.method().clone();
|
||||
let uri = req.uri().clone();
|
||||
req.extensions_mut().insert(RequestId(id.clone()));
|
||||
let (parts, body) = req.into_parts();
|
||||
let request_bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
|
||||
tracing::info!(request_id=%id, %method, %uri, body=%pretty(&request_bytes), "request started");
|
||||
let response = next
|
||||
.run(Request::from_parts(parts, Body::from(request_bytes)))
|
||||
.await;
|
||||
let status = response.status();
|
||||
let (parts, body) = response.into_parts();
|
||||
let response_bytes = to_bytes(body, usize::MAX).await.unwrap_or_default();
|
||||
tracing::info!(request_id=%id, status=%status, elapsed_ms=started.elapsed().as_millis(), body=%pretty(&response_bytes), "request completed");
|
||||
Response::from_parts(parts, Body::from(response_bytes))
|
||||
}
|
||||
|
||||
pub fn log_info<T: std::fmt::Debug>(request_id: &RequestId, label: &str, data: T) {
|
||||
tracing::info!(request_id=%request_id.0, %label, data=?data);
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn truncates_at_character_boundary() {
|
||||
assert_eq!(truncate_string("中文测试", 2), "中文.....");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
pub mod auth;
|
||||
pub mod body_limit;
|
||||
pub mod language;
|
||||
pub mod logging;
|
||||
pub mod rate_limit;
|
||||
pub mod security;
|
||||
pub mod user_id;
|
||||
|
||||
pub use language::Language;
|
||||
pub use user_id::UserId;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use axum::{
|
||||
extract::{ConnectInfo, Request, State},
|
||||
http::StatusCode,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
net::SocketAddr,
|
||||
sync::Arc,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct RateLimiter {
|
||||
limit: u32,
|
||||
entries: Arc<Mutex<HashMap<String, (Instant, u32)>>>,
|
||||
}
|
||||
impl RateLimiter {
|
||||
pub fn new(limit: u32) -> Self {
|
||||
Self {
|
||||
limit,
|
||||
entries: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
async fn allow(&self, key: String) -> bool {
|
||||
let mut entries = self.entries.lock().await;
|
||||
let value = entries.entry(key).or_insert((Instant::now(), 0));
|
||||
if value.0.elapsed() >= Duration::from_secs(60) {
|
||||
*value = (Instant::now(), 0);
|
||||
}
|
||||
if value.1 >= self.limit {
|
||||
false
|
||||
} else {
|
||||
value.1 += 1;
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
pub async fn rate_limit_middleware(
|
||||
State(limiter): State<RateLimiter>,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
let key = req
|
||||
.extensions()
|
||||
.get::<ConnectInfo<SocketAddr>>()
|
||||
.map(|v| v.0.ip().to_string())
|
||||
.or_else(|| {
|
||||
req.headers()
|
||||
.get("x-forwarded-for")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(|v| v.split(',').next())
|
||||
.map(str::trim)
|
||||
.map(str::to_string)
|
||||
})
|
||||
.unwrap_or_else(|| "unknown".into());
|
||||
if limiter.allow(key).await {
|
||||
next.run(req).await
|
||||
} else {
|
||||
(StatusCode::TOO_MANY_REQUESTS, "rate limit exceeded").into_response()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
use crate::error::ErrorResponse;
|
||||
use axum::response::IntoResponse;
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{
|
||||
header::{HeaderName, HeaderValue},
|
||||
StatusCode,
|
||||
},
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
|
||||
pub async fn security_headers(req: Request, next: Next) -> Response {
|
||||
let mut response = next.run(req).await;
|
||||
let headers = response.headers_mut();
|
||||
headers.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
headers.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
headers.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
headers.insert(
|
||||
HeaderName::from_static("permissions-policy"),
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
pub async fn fallback_404() -> Response {
|
||||
ErrorResponse::not_found("route not found").into_response()
|
||||
}
|
||||
pub async fn fallback_405() -> Response {
|
||||
ErrorResponse {
|
||||
status: StatusCode::METHOD_NOT_ALLOWED,
|
||||
message: "method not allowed".into(),
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
use crate::{
|
||||
error::ErrorResponse,
|
||||
infra::middleware::Language,
|
||||
utils::i18n::{message, ZH_CN},
|
||||
};
|
||||
use async_trait::async_trait;
|
||||
use axum::extract::FromRequestParts;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserId(pub String);
|
||||
|
||||
#[async_trait]
|
||||
impl<S: Send + Sync> FromRequestParts<S> for UserId {
|
||||
type Rejection = ErrorResponse;
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_: &S,
|
||||
) -> Result<Self, Self::Rejection> {
|
||||
let lang = parts
|
||||
.extensions
|
||||
.get::<Language>()
|
||||
.map(|v| v.0.as_str())
|
||||
.unwrap_or(ZH_CN);
|
||||
parts.extensions.get::<UserId>().cloned().ok_or_else(|| {
|
||||
ErrorResponse::unauthorized(message(lang, "未找到用户身份", "User identity not found"))
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user