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
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user