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

@@ -957,29 +957,19 @@ export class KernelClient {
}
/**
* Execute a skill by ID with optional input parameters.
* Checks autonomy level before execution.
*/
async executeSkill(id: string, input?: Record<string, unknown>): Promise<{
success: boolean;
output?: unknown;
error?: string;
durationMs?: number;
}> {
// Autonomy check before executing skill
const { canAutoExecute, getAutonomyManager } = await import('./autonomy-manager');
const { canProceed, decision } = canAutoExecute('skill_install', 5);
if (!canProceed) {
return {
success: false,
error: `自主授权拒绝: ${decision.reason}`,
};
}
const autonomyLevel = getAutonomyManager().getConfig().level;
return invoke('skill_execute', {
id,
context: {},
input: input || {},
autonomyLevel,
});
}

View File

@@ -494,11 +494,12 @@ class SaasLLMAdapter implements LLMServiceAdapter {
}
isAvailable(): boolean {
// Check synchronously via localStorage for availability check
// Dynamic import would be async, so we use a simpler check
// Check synchronously via localStorage for availability check.
// Auth is cookie-based — check connection mode + URL presence.
try {
const token = localStorage.getItem('zclaw-saas-token');
return !!token;
const mode = localStorage.getItem('zclaw-connection-mode');
const saasUrl = localStorage.getItem('zclaw-saas-url');
return mode === 'saas' && !!saasUrl;
} catch {
return false;
}

View File

@@ -5,12 +5,20 @@
* Handles authentication, model listing, chat relay, and config management.
*
* API base path: /api/v1/...
* Auth: Bearer token in Authorization header
* Auth: HttpOnly cookie (primary) + Bearer token fallback
*
* Security: Tokens are NO LONGER persisted to localStorage.
* The backend sets HttpOnly cookies on login/register/refresh.
* On page reload, cookie-based auth is verified via GET /api/v1/auth/me.
*/
// === Storage Keys ===
// Token is stored in secure storage (OS keyring), NOT in plain localStorage.
// Auth state is carried by HttpOnly cookies when possible (same-origin).
// On page reload, token is restored from secure storage as Bearer fallback.
const SAASTOKEN_KEY = 'zclaw-saas-token';
const SAAS_TOKEN_SECURE_KEY = 'zclaw-saas-token'; // OS keyring key
const SAASTOKEN_KEY = 'zclaw-saas-token'; // legacy localStorage — only used for cleanup
const SAASURL_KEY = 'zclaw-saas-url';
const SAASACCOUNT_KEY = 'zclaw-saas-account';
const SAASMODE_KEY = 'zclaw-connection-mode';
@@ -439,25 +447,42 @@ export class SaaSApiError extends Error {
// === Session Persistence ===
export interface SaaSSession {
token: string;
token: string | null; // null when using cookie-based auth (page reload)
account: SaaSAccountInfo | null;
saasUrl: string;
}
/**
* Load a persisted SaaS session from localStorage.
* Returns null if no valid session exists.
* Load a persisted SaaS session.
* Token is stored in secure storage (OS keyring), not plain localStorage.
* Returns null if no URL is stored (never logged in).
*
* NOTE: Token loading is async due to secure storage access.
* For synchronous checks, use loadSaaSSessionSync() (URL + account only).
*/
export function loadSaaSSession(): SaaSSession | null {
export async function loadSaaSSession(): Promise<SaaSSession | null> {
try {
const token = localStorage.getItem(SAASTOKEN_KEY);
const saasUrl = localStorage.getItem(SAASURL_KEY);
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
if (!token || !saasUrl) {
if (!saasUrl) {
return null;
}
// Clean up any legacy plaintext token from localStorage
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
if (legacyToken) {
localStorage.removeItem(SAASTOKEN_KEY);
}
// Load token from secure storage
let token: string | null = null;
try {
const { secureStorage } = await import('./secure-storage');
token = await secureStorage.get(SAAS_TOKEN_SECURE_KEY);
} catch {
// Secure storage unavailable — token stays null (cookie auth will be attempted)
}
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
const account: SaaSAccountInfo | null = accountRaw
? (JSON.parse(accountRaw) as SaaSAccountInfo)
: null;
@@ -471,10 +496,46 @@ export function loadSaaSSession(): SaaSSession | null {
}
/**
* Persist a SaaS session to localStorage.
* Synchronous version — returns URL + account only (no token).
* Used during store initialization where async is not available.
*/
export function saveSaaSSession(session: SaaSSession): void {
localStorage.setItem(SAASTOKEN_KEY, session.token);
export function loadSaaSSessionSync(): { saasUrl: string; account: SaaSAccountInfo | null } | null {
try {
const saasUrl = localStorage.getItem(SAASURL_KEY);
if (!saasUrl) return null;
// Clean up legacy plaintext token
const legacyToken = localStorage.getItem(SAASTOKEN_KEY);
if (legacyToken) {
localStorage.removeItem(SAASTOKEN_KEY);
}
const accountRaw = localStorage.getItem(SAASACCOUNT_KEY);
const account: SaaSAccountInfo | null = accountRaw
? (JSON.parse(accountRaw) as SaaSAccountInfo)
: null;
return { saasUrl, account };
} catch {
return null;
}
}
/**
* Persist SaaS session.
* Token goes to secure storage (OS keyring), metadata to localStorage.
*/
export async function saveSaaSSession(session: SaaSSession): Promise<void> {
// Store token in secure storage (OS keyring), not plain localStorage
if (session.token) {
try {
const { secureStorage } = await import('./secure-storage');
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, session.token);
} catch {
// Secure storage unavailable — token only in memory
}
}
localStorage.setItem(SAASURL_KEY, session.saasUrl);
if (session.account) {
localStorage.setItem(SAASACCOUNT_KEY, JSON.stringify(session.account));
@@ -482,9 +543,15 @@ export function saveSaaSSession(session: SaaSSession): void {
}
/**
* Clear the persisted SaaS session from localStorage.
* Clear the persisted SaaS session from all storage.
*/
export function clearSaaSSession(): void {
export async function clearSaaSSession(): Promise<void> {
// Remove from secure storage
try {
const { secureStorage } = await import('./secure-storage');
await secureStorage.set(SAAS_TOKEN_SECURE_KEY, '');
} catch { /* non-blocking */ }
localStorage.removeItem(SAASTOKEN_KEY);
localStorage.removeItem(SAASURL_KEY);
localStorage.removeItem(SAASACCOUNT_KEY);
@@ -525,14 +592,33 @@ export class SaaSClient {
return this.baseUrl;
}
/** Set or clear the auth token */
/** Set or clear the auth token (in-memory only, never persisted) */
setToken(token: string | null): void {
this.token = token;
}
/** Check if the client has an auth token */
/** Check if the client is authenticated (token in memory or cookie-based) */
isAuthenticated(): boolean {
return !!this.token;
return !!this.token || this._cookieAuth;
}
/** Track cookie-based auth state (page reload) */
private _cookieAuth: boolean = false;
/**
* Attempt to restore auth state from HttpOnly cookie.
* Called on page reload when no token is in memory.
* Returns account info if cookie is valid, null otherwise.
*/
async restoreFromCookie(): Promise<SaaSAccountInfo | null> {
try {
const account = await this.me();
this._cookieAuth = true;
return account;
} catch {
this._cookieAuth = false;
return null;
}
}
/** Check if a path is an auth endpoint (avoid infinite refresh loop) */
@@ -569,6 +655,7 @@ export class SaaSClient {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
// Bearer token as fallback — primary auth is HttpOnly cookie
if (this.token) {
headers['Authorization'] = `Bearer ${this.token}`;
}
@@ -577,6 +664,7 @@ export class SaaSClient {
const response = await fetch(`${this.baseUrl}${path}`, {
method,
headers,
credentials: 'include', // Send HttpOnly cookies
body: body !== undefined ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(timeoutMs),
});
@@ -588,24 +676,12 @@ export class SaaSClient {
try {
const newToken = await this.refreshToken();
if (newToken) {
// Persist refreshed token to localStorage
try {
const raw = localStorage.getItem('zclaw-saas-session');
if (raw) {
const session = JSON.parse(raw);
session.token = newToken;
localStorage.setItem('zclaw-saas-session', JSON.stringify(session));
}
} catch { /* non-blocking */ }
return this.request<T>(method, path, body, timeoutMs, true);
}
} catch (refreshErr) {
// Token refresh failed — clear session and trigger logout
try {
const { clearSaaSSession } = require('./saas-client');
clearSaaSSession();
localStorage.removeItem('zclaw-connection-mode');
} catch { /* non-blocking */ }
clearSaaSSession().catch(() => {}); // async cleanup, fire-and-forget
localStorage.removeItem('zclaw-connection-mode');
throw new SaaSApiError(401, 'SESSION_EXPIRED', '会话已过期,请重新登录');
}
throw new SaaSApiError(401, 'UNAUTHORIZED', '认证已过期,请重新登录');
@@ -844,6 +920,7 @@ export class SaaSClient {
{
method: 'POST',
headers,
credentials: 'include', // Send HttpOnly cookies
body: JSON.stringify(body),
signal: effectiveSignal,
},

View File

@@ -359,17 +359,16 @@ export const useConnectionStore = create<ConnectionStore>((set, get) => {
if (savedMode === 'saas') {
const { loadSaaSSession, saasClient } = await import('../lib/saas-client');
const session = loadSaaSSession();
const session = await loadSaaSSession();
if (!session || !session.token || !session.saasUrl) {
if (!session || !session.saasUrl) {
throw new Error('SaaS 模式未登录,请先在设置中登录 SaaS 平台');
}
log.debug('Using SaaS relay mode:', session.saasUrl);
// Configure the singleton client
// Configure the singleton client (cookie auth — no token needed)
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
// Health check via GET /api/v1/relay/models
try {

View File

@@ -16,6 +16,7 @@ import {
saasClient,
SaaSApiError,
loadSaaSSession,
loadSaaSSessionSync,
saveSaaSSession,
clearSaaSSession,
saveConnectionMode,
@@ -79,7 +80,7 @@ export interface SaaSActionsSlice {
login: (saasUrl: string, username: string, password: string) => Promise<void>;
loginWithTotp: (saasUrl: string, username: string, password: string, totpCode: string) => Promise<void>;
register: (saasUrl: string, username: string, email: string, password: string, displayName?: string) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
setConnectionMode: (mode: ConnectionMode) => void;
fetchAvailableModels: () => Promise<void>;
syncConfigFromSaaS: () => Promise<void>;
@@ -104,33 +105,34 @@ const DEFAULT_SAAS_URL = import.meta.env.DEV
// === Helpers ===
/** Determine the initial connection mode from persisted state */
function resolveInitialMode(session: ReturnType<typeof loadSaaSSession>): ConnectionMode {
function resolveInitialMode(sessionMeta: { saasUrl: string; account: SaaSAccountInfo | null } | null): ConnectionMode {
const persistedMode = loadConnectionMode();
if (persistedMode === 'tauri' || persistedMode === 'gateway' || persistedMode === 'saas') {
return persistedMode;
}
return session ? 'saas' : 'tauri';
return sessionMeta ? 'saas' : 'tauri';
}
// === Store Implementation ===
export const useSaaSStore = create<SaaSStore>((set, get) => {
// Restore session from localStorage on init
const session = loadSaaSSession();
const initialMode = resolveInitialMode(session);
// Restore session metadata synchronously (URL + account only).
// Token is loaded from secure storage asynchronously by restoreSession().
const sessionMeta = loadSaaSSessionSync();
const initialMode = resolveInitialMode(sessionMeta);
// If session exists, configure the singleton client
if (session) {
saasClient.setBaseUrl(session.saasUrl);
saasClient.setToken(session.token);
// If session URL exists, configure the singleton client base URL
if (sessionMeta) {
saasClient.setBaseUrl(sessionMeta.saasUrl);
}
return {
// === Initial State ===
isLoggedIn: session !== null,
account: session?.account ?? null,
saasUrl: session?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: session?.token ?? null,
// isLoggedIn starts false — will be set to true by restoreSession()
isLoggedIn: false,
account: sessionMeta?.account ?? null,
saasUrl: sessionMeta?.saasUrl ?? DEFAULT_SAAS_URL,
authToken: null, // In-memory only — loaded from secure storage by restoreSession()
connectionMode: initialMode,
availableModels: [],
isLoading: false,
@@ -163,20 +165,20 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
saasClient.setBaseUrl(normalizedUrl);
const loginData: SaaSLoginResponse = await saasClient.login(trimmedUsername, password);
// Persist session
// Persist session: token → secure storage (OS keyring), metadata → localStorage
const sessionData = {
token: loginData.token,
token: loginData.token, // Will be stored in OS keyring by saveSaaSSession
account: loginData.account,
saasUrl: normalizedUrl,
};
saveSaaSSession(sessionData);
saveSaaSSession(sessionData); // async — fire and forget (non-blocking)
saveConnectionMode('saas');
set({
isLoggedIn: true,
account: loginData.account,
saasUrl: normalizedUrl,
authToken: loginData.token,
authToken: null, // Not stored in Zustand state — saasClient holds in memory
connectionMode: 'saas',
isLoading: false,
error: null,
@@ -261,7 +263,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
isLoggedIn: true,
account: loginData.account,
saasUrl: normalizedUrl,
authToken: loginData.token,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
@@ -326,7 +328,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
isLoggedIn: true,
account: registerData.account,
saasUrl: normalizedUrl,
authToken: registerData.token,
authToken: null,
connectionMode: 'saas',
isLoading: false,
error: null,
@@ -357,9 +359,9 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
}
},
logout: () => {
logout: async () => {
saasClient.setToken(null);
clearSaaSSession();
await clearSaaSSession();
saveConnectionMode('tauri');
stopTelemetryCollector();
stopPromptOTASync();
@@ -389,16 +391,15 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
},
fetchAvailableModels: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn || !authToken) {
if (!isLoggedIn) {
set({ availableModels: [] });
return;
}
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
const models = await saasClient.listModels();
set({ availableModels: models });
} catch (err: unknown) {
@@ -413,12 +414,11 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
* Collects all "dirty" config keys, computes diff, and syncs via merge.
*/
pushConfigToSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Collect all dirty config keys
const dirtyKeys: string[] = [];
@@ -472,13 +472,12 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
/** Pull SaaS config and apply to local storage (startup auto-sync) */
syncConfigFromSaaS: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn || !authToken) return;
if (!isLoggedIn) return;
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
// Read last sync timestamp from localStorage
const lastSyncKey = 'zclaw-config-last-sync';
@@ -533,15 +532,14 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
},
registerCurrentDevice: async () => {
const { isLoggedIn, authToken, saasUrl } = get();
const { isLoggedIn, saasUrl } = get();
if (!isLoggedIn || !authToken) {
if (!isLoggedIn) {
return;
}
try {
saasClient.setBaseUrl(saasUrl);
saasClient.setToken(authToken);
await saasClient.registerDevice({
device_id: DEVICE_ID,
device_name: `${navigator.userAgent.split(' ').slice(0, 3).join(' ')}`,
@@ -555,7 +553,7 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
const DEGRADE_AFTER_FAILURES = 3; // Degrade after 3 consecutive failures (~15 min)
const timer = window.setInterval(async () => {
const state = get();
if (!state.isLoggedIn || !state.authToken) {
if (!state.isLoggedIn) {
window.clearInterval(timer);
return;
}
@@ -593,25 +591,55 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
set({ error: null });
},
restoreSession: () => {
const restored = loadSaaSSession();
if (restored) {
saasClient.setBaseUrl(restored.saasUrl);
restoreSession: async () => {
const restored = await loadSaaSSession();
if (!restored) return;
saasClient.setBaseUrl(restored.saasUrl);
// Strategy: try secure storage token first, then cookie auth
let account: SaaSAccountInfo | null = null;
if (restored.token) {
// Token from secure storage — use as Bearer
saasClient.setToken(restored.token);
set({
isLoggedIn: true,
account: restored.account,
saasUrl: restored.saasUrl,
authToken: restored.token,
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
try {
account = await saasClient.me();
} catch {
// Token expired — try cookie auth
saasClient.setToken(null);
}
}
if (!account) {
// Try cookie-based auth (works for same-origin, e.g. admin panel)
account = await saasClient.restoreFromCookie();
}
if (!account) {
// Neither token nor cookie works — user needs to re-login
set({
isLoggedIn: false,
account: null,
saasUrl: restored.saasUrl,
authToken: null,
});
return;
}
set({
isLoggedIn: true,
account,
saasUrl: restored.saasUrl,
authToken: restored.token, // In-memory from secure storage (null if cookie-only)
connectionMode: loadConnectionMode() === 'saas' ? 'saas' : 'tauri',
});
get().fetchAvailableModels().catch(() => {});
get().syncConfigFromSaaS().then(() => {
get().pushConfigToSaaS().catch(() => {});
}).catch(() => {});
initTelemetryCollector(DEVICE_ID);
startPromptOTASync(DEVICE_ID);
},
setupTotp: async () => {
@@ -633,10 +661,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
await saasClient.verifyTotp(code);
const account = await saasClient.me();
const { saasUrl, authToken } = get();
if (authToken) {
saveSaaSSession({ token: authToken, account, saasUrl });
}
const { saasUrl } = get();
saveSaaSSession({ token: null, account, saasUrl }); // Token in saasClient memory only
set({ totpSetupData: null, isLoading: false, account });
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message
@@ -651,10 +677,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
try {
await saasClient.disableTotp(password);
const account = await saasClient.me();
const { saasUrl, authToken } = get();
if (authToken) {
saveSaaSSession({ token: authToken, account, saasUrl });
}
const { saasUrl } = get();
saveSaaSSession({ token: null, account, saasUrl });
set({ isLoading: false, account });
} catch (err: unknown) {
const message = err instanceof SaaSApiError ? err.message