feat(security): Auth Token HttpOnly Cookie — XSS 安全加固

后端:
- axum-extra 启用 cookie feature
- login/register/refresh 设置 HttpOnly + Secure + SameSite=Strict cookies
- 新增 POST /api/v1/auth/logout 清除 cookies
- auth_middleware 支持 cookie 提取路径(fallback from header)
- CORS: 添加 allow_credentials(true) + COOKIE header

前端 (admin-v2):
- authStore: token 仅存内存,不再写 localStorage(account 保留)
- request.ts: 添加 withCredentials: true 发送 cookies
- 修复 refresh token rotation bug(之前不更新 stored refreshToken)
- logout 调用后端清除 cookie 端点

向后兼容: API 客户端仍可用 Authorization: Bearer header
Desktop (Ed25519 设备认证) 完全不受影响
This commit is contained in:
iven
2026-03-30 19:30:42 +08:00
parent e7b2d1c099
commit c2aff09811
6 changed files with 174 additions and 60 deletions

View File

@@ -1,6 +1,10 @@
// ============================================================
// ZCLAW Admin V2 — Zustand 认证状态管理
// ============================================================
//
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
// 内存中的 token/refreshToken 仅用于 Authorization header fallbackAPI 客户端兼容)。
import { create } from 'zustand'
import type { AccountPublic } from '@/types'
@@ -14,25 +18,22 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
],
admin: [
'account:read', 'account:admin', 'provider:manage', 'model:read',
'model:manage', 'relay:use', 'relay:admin', 'config:read',
'model:manage', 'relay:use', 'config:read',
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
const TOKEN_KEY = 'zclaw_admin_token'
const REFRESH_KEY = 'zclaw_admin_refresh_token'
const ACCOUNT_KEY = 'zclaw_admin_account'
function loadFromStorage(): { token: string | null; refreshToken: string | null; account: AccountPublic | null } {
const token = localStorage.getItem(TOKEN_KEY)
const refreshToken = localStorage.getItem(REFRESH_KEY)
/** 从 localStorage 恢复 account 信息token 通过 HttpOnly cookie 管理) */
function loadFromStorage(): { account: AccountPublic | null } {
const raw = localStorage.getItem(ACCOUNT_KEY)
let account: AccountPublic | null = null
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
return { token, refreshToken, account }
return { account }
}
interface AuthState {
@@ -42,6 +43,7 @@ interface AuthState {
permissions: string[]
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
login: (token: string, refreshToken: string, account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
@@ -49,23 +51,28 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set, get) => {
const stored = loadFromStorage()
const perms = stored.account ? (ROLE_PERMISSIONS[stored.account.role] ?? []) : []
const perms = stored.account?.role
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
: []
return {
token: stored.token,
refreshToken: stored.refreshToken,
token: null,
refreshToken: null,
account: stored.account,
permissions: perms,
setToken: (token: string) => {
localStorage.setItem(TOKEN_KEY, token)
set({ token })
},
setRefreshToken: (refreshToken: string) => {
set({ refreshToken })
},
login: (token: string, refreshToken: string, account: AccountPublic) => {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(REFRESH_KEY, refreshToken)
// account 保留 localStorage仅用于 UI 显示,非敏感)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
// token 仅存内存(实际认证通过 HttpOnly cookie
set({
token,
refreshToken,
@@ -75,10 +82,10 @@ export const useAuthStore = create<AuthState>((set, get) => {
},
logout: () => {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(ACCOUNT_KEY)
set({ token: null, refreshToken: null, account: null, permissions: [] })
// 调用后端 logout 清除 HttpOnly cookiesfire-and-forget
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
},
hasPermission: (permission: string) => {