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

@@ -0,0 +1,127 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ============================================================
//
// 认证策略: 主路径使用 HttpOnly cookie浏览器自动附加
// Authorization header 作为 fallback 保留用于 API 客户端。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 请求拦截器:附加 Authorization header fallback ──────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach((cb) => cb(newToken))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
withCredentials: true, // 发送 refresh cookie
})
const newToken = res.data.token as string
const newRefreshToken = res.data.refresh_token as string
// 更新内存中的 token实际认证通过 HttpOnly cookie浏览器已自动更新
store.setToken(newToken)
if (newRefreshToken) {
store.setRefreshToken(newRefreshToken)
}
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
} catch {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
return Promise.reject(error)
},
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}