feat: 新增管理后台前端项目及安全加固
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

refactor(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流

fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型

feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入

chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点

docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明

test: 完善配置解析单元测试
- 新增环境变量插值测试用例
This commit is contained in:
iven
2026-03-31 00:11:33 +08:00
parent 6821df5f44
commit eb956d0dce
129 changed files with 11913 additions and 863 deletions

View File

@@ -19,14 +19,16 @@ const ACCESS_TOKEN_COOKIE: &str = "zclaw_access_token";
const REFRESH_TOKEN_COOKIE: &str = "zclaw_refresh_token";
/// 构建 auth cookies 并附加到 CookieJar
/// secure 标记在开发环境 (ZCLAW_SAAS_DEV=true) 设为 false生产设为 true
fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJar {
let access_max_age = std::time::Duration::from_secs(2 * 3600); // 2h
let refresh_max_age = std::time::Duration::from_secs(7 * 86400); // 7d
let secure = !is_dev_mode();
// cookie crate 需要 time::Duration从 std 转换
let access = Cookie::build((ACCESS_TOKEN_COOKIE, token.to_string()))
.http_only(true)
.secure(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/api")
.max_age(access_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(3600).try_into().unwrap()))
@@ -34,7 +36,7 @@ fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJ
let refresh = Cookie::build((REFRESH_TOKEN_COOKIE, refresh_token.to_string()))
.http_only(true)
.secure(true)
.secure(secure)
.same_site(SameSite::Strict)
.path("/api/v1/auth")
.max_age(refresh_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(86400).try_into().unwrap()))
@@ -43,6 +45,13 @@ fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJ
jar.add(access).add(refresh)
}
/// 检查是否为开发模式Cookie Secure、CORS 等安全策略依据此判断)
fn is_dev_mode() -> bool {
std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false)
}
/// 清除 auth cookies
fn clear_auth_cookies(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::build(ACCESS_TOKEN_COOKIE).path("/api"))
@@ -502,9 +511,40 @@ fn sha256_hex(input: &str) -> String {
hex::encode(Sha256::digest(input.as_bytes()))
}
/// POST /api/v1/auth/logout — 清除 auth cookies
/// POST /api/v1/auth/logout — 撤销 refresh token 并清除 auth cookies
pub async fn logout(
State(state): State<AppState>,
jar: CookieJar,
) -> (CookieJar, axum::http::StatusCode) {
// 尝试从 cookie 中获取 refresh token 并撤销
if let Some(refresh_cookie) = jar.get(REFRESH_TOKEN_COOKIE) {
let token = refresh_cookie.value();
if let Ok(claims) = verify_token_skip_expiry(token, state.jwt_secret.expose_secret()) {
if claims.token_type == "refresh" {
if let Some(jti) = claims.jti {
let now = chrono::Utc::now().to_rfc3339();
// 标记 refresh token 为已使用(等效于撤销/黑名单)
let result = sqlx::query(
"UPDATE refresh_tokens SET used_at = $1 WHERE jti = $2 AND used_at IS NULL"
)
.bind(&now).bind(&jti)
.execute(&state.db)
.await;
match result {
Ok(r) => {
if r.rows_affected() > 0 {
tracing::info!(account_id = %claims.sub, jti = %jti, "Refresh token revoked on logout");
}
}
Err(e) => {
tracing::warn!(jti = %jti, error = %e, "Failed to revoke refresh token on logout");
}
}
}
}
}
}
(clear_auth_cookies(jar), axum::http::StatusCode::NO_CONTENT)
}