feat(saas): Phase 1 后端能力补强 — API Token 认证、真实 SSE 流式、速率限制
Phase 1.1: API Token 认证中间件 - auth_middleware 新增 zclaw_ 前缀 token 分支 (SHA-256 验证) - 合并 token 自身权限与角色权限,异步更新 last_used_at - 添加 GET /api/v1/auth/me 端点返回当前用户信息 - get_role_permissions 改为 pub(crate) 供中间件调用 Phase 1.2: 真实 SSE 流式中转 - RelayResponse::Sse 改为 axum::body::Body (bytes_stream) - 流式请求超时提升至 300s,转发 SSE headers (Cache-Control, Connection) - 添加 futures 依赖用于 StreamExt Phase 1.3: 滑动窗口速率限制中间件 - 按 account_id 做 per-minute 限流 (默认 60 rpm + 10 burst) - 超限返回 429 + Retry-After header - RateLimitConfig 支持配置化,DashMap 存储时间戳 21 tests passed, zero warnings.
This commit is contained in:
@@ -16,6 +16,70 @@ use crate::error::SaasError;
|
||||
use crate::state::AppState;
|
||||
use types::AuthContext;
|
||||
|
||||
/// 通过 API Token 验证身份
|
||||
///
|
||||
/// 流程: SHA-256 哈希 → 查 api_tokens 表 → 检查有效期 → 获取关联账号角色权限 → 更新 last_used_at
|
||||
async fn verify_api_token(state: &AppState, raw_token: &str) -> Result<AuthContext, SaasError> {
|
||||
use sha2::{Sha256, Digest};
|
||||
|
||||
let token_hash = hex::encode(Sha256::digest(raw_token.as_bytes()));
|
||||
|
||||
let row: Option<(String, Option<String>, String)> = sqlx::query_as(
|
||||
"SELECT account_id, expires_at, permissions FROM api_tokens
|
||||
WHERE token_hash = ?1 AND revoked_at IS NULL"
|
||||
)
|
||||
.bind(&token_hash)
|
||||
.fetch_optional(&state.db)
|
||||
.await?;
|
||||
|
||||
let (account_id, expires_at, permissions_json) = row
|
||||
.ok_or(SaasError::Unauthorized)?;
|
||||
|
||||
// 检查是否过期
|
||||
if let Some(ref exp) = expires_at {
|
||||
let now = chrono::Utc::now();
|
||||
if let Ok(exp_time) = chrono::DateTime::parse_from_rfc3339(exp) {
|
||||
if now >= exp_time.with_timezone(&chrono::Utc) {
|
||||
return Err(SaasError::Unauthorized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 查询关联账号的角色
|
||||
let (role,): (String,) = sqlx::query_as(
|
||||
"SELECT role FROM accounts WHERE id = ?1 AND status = 'active'"
|
||||
)
|
||||
.bind(&account_id)
|
||||
.fetch_optional(&state.db)
|
||||
.await?
|
||||
.ok_or(SaasError::Unauthorized)?;
|
||||
|
||||
// 合并 token 权限与角色权限(去重)
|
||||
let role_permissions = handlers::get_role_permissions(&state.db, &role).await?;
|
||||
let token_permissions: Vec<String> = serde_json::from_str(&permissions_json).unwrap_or_default();
|
||||
let mut permissions = role_permissions;
|
||||
for p in token_permissions {
|
||||
if !permissions.contains(&p) {
|
||||
permissions.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// 异步更新 last_used_at(不阻塞请求)
|
||||
let db = state.db.clone();
|
||||
tokio::spawn(async move {
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
let _ = sqlx::query("UPDATE api_tokens SET last_used_at = ?1 WHERE token_hash = ?2")
|
||||
.bind(&now).bind(&token_hash)
|
||||
.execute(&db).await;
|
||||
});
|
||||
|
||||
Ok(AuthContext {
|
||||
account_id,
|
||||
role,
|
||||
permissions,
|
||||
})
|
||||
}
|
||||
|
||||
/// 认证中间件: 从 JWT 或 API Token 提取身份
|
||||
pub async fn auth_middleware(
|
||||
State(state): State<AppState>,
|
||||
@@ -28,13 +92,19 @@ pub async fn auth_middleware(
|
||||
|
||||
let result = if let Some(auth) = auth_header {
|
||||
if let Some(token) = auth.strip_prefix("Bearer ") {
|
||||
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
||||
.map(|claims| AuthContext {
|
||||
account_id: claims.sub,
|
||||
role: claims.role,
|
||||
permissions: claims.permissions,
|
||||
})
|
||||
.map_err(|_| SaasError::Unauthorized)
|
||||
if token.starts_with("zclaw_") {
|
||||
// API Token 路径
|
||||
verify_api_token(&state, token).await
|
||||
} else {
|
||||
// JWT 路径
|
||||
jwt::verify_token(token, state.jwt_secret.expose_secret())
|
||||
.map(|claims| AuthContext {
|
||||
account_id: claims.sub,
|
||||
role: claims.role,
|
||||
permissions: claims.permissions,
|
||||
})
|
||||
.map_err(|_| SaasError::Unauthorized)
|
||||
}
|
||||
} else {
|
||||
Err(SaasError::Unauthorized)
|
||||
}
|
||||
@@ -62,8 +132,9 @@ pub fn routes() -> axum::Router<AppState> {
|
||||
|
||||
/// 需要认证的路由
|
||||
pub fn protected_routes() -> axum::Router<AppState> {
|
||||
use axum::routing::post;
|
||||
use axum::routing::{get, post};
|
||||
|
||||
axum::Router::new()
|
||||
.route("/api/v1/auth/refresh", post(handlers::refresh))
|
||||
.route("/api/v1/auth/me", get(handlers::me))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user