fix(security): implement all 15 security fixes from penetration test V1
Security audit (2026-03-31): 5 HIGH + 10 MEDIUM issues, all fixed. HIGH: - H1: JWT password_version mechanism (pwv in Claims, middleware verification, auto-increment on password change) - H2: Docker saas port bound to 127.0.0.1 - H3: TOTP encryption key decoupled from JWT secret (production bailout) - H4+H5: Tauri CSP hardened (removed unsafe-inline, restricted connect-src) MEDIUM: - M1: Persistent rate limiting (PostgreSQL rate_limit_events table) - M2: Account lockout (5 failures -> 15min lock) - M3: RFC 5322 email validation with regex - M4: Device registration typed struct with length limits - M5: Provider URL validation on create/update (SSRF prevention) - M6: Legacy TOTP secret migration (fixed nonce -> random nonce) - M7: Legacy frontend crypto migration (static salt -> random salt) - M8+M9: Admin frontend: removed JS token storage, HttpOnly cookie only - M10: Pipeline debug log sanitization (keys only, 100-char truncation) Also: fixed CLAUDE.md Section 12 (was corrupted), added title.rs middleware skeleton, fixed RegisterDeviceRequest visibility.
This commit is contained in:
@@ -28,7 +28,7 @@ export default function Login() {
|
||||
}
|
||||
|
||||
const res = await authService.login(data)
|
||||
loginStore(res.token, res.refresh_token, res.account)
|
||||
loginStore(res.account)
|
||||
|
||||
message.success('登录成功')
|
||||
const from = searchParams.get('from') || '/'
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
// ============================================================
|
||||
//
|
||||
// Auth strategy:
|
||||
// 1. If Zustand has token (normal flow after login) → authenticated
|
||||
// 2. If no token but account in localStorage → call GET /auth/me
|
||||
// 1. If Zustand has isAuthenticated=true (normal flow after login) -> authenticated
|
||||
// 2. If isAuthenticated=false but account in localStorage -> call GET /auth/me
|
||||
// to validate HttpOnly cookie and restore session
|
||||
// 3. If cookie invalid → clean up and redirect to /login
|
||||
// 3. If cookie invalid -> clean up and redirect to /login
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Navigate, useLocation } from 'react-router-dom'
|
||||
@@ -15,7 +15,7 @@ import { useAuthStore } from '@/stores/authStore'
|
||||
import { authService } from '@/services/auth'
|
||||
|
||||
export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
const token = useAuthStore((s) => s.token)
|
||||
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
|
||||
const account = useAuthStore((s) => s.account)
|
||||
const login = useAuthStore((s) => s.login)
|
||||
const logout = useAuthStore((s) => s.logout)
|
||||
@@ -29,15 +29,14 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
if (restoreAttempted.current) return
|
||||
restoreAttempted.current = true
|
||||
|
||||
// If no in-memory token but account exists in localStorage,
|
||||
// If not authenticated but account exists in localStorage,
|
||||
// try to validate the HttpOnly cookie via /auth/me
|
||||
if (!token && account) {
|
||||
if (!isAuthenticated && account) {
|
||||
setRestoring(true)
|
||||
authService.me()
|
||||
.then((meAccount) => {
|
||||
// Cookie is valid — restore session
|
||||
// Use sentinel token since real auth is via HttpOnly cookie
|
||||
login('cookie-session', '', meAccount)
|
||||
login(meAccount)
|
||||
setRestoring(false)
|
||||
})
|
||||
.catch(() => {
|
||||
@@ -56,7 +55,7 @@ export function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
}
|
||||
|
||||
if (!token) {
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" state={{ from: location }} replace />
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// ============================================================
|
||||
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
|
||||
// ZCLAW Admin V2 — Axios 实例 + 认证拦截器
|
||||
// ============================================================
|
||||
//
|
||||
// 认证策略: 主路径使用 HttpOnly cookie(浏览器自动附加),
|
||||
// Authorization header 作为 fallback 保留用于 API 客户端。
|
||||
// 认证策略: HttpOnly cookie(浏览器自动附加到同域请求)。
|
||||
// 所有 token 均通过 cookie 传递,前端 JS 无法读取。
|
||||
// withCredentials: true 确保浏览器发送 HttpOnly cookie。
|
||||
|
||||
import axios from 'axios'
|
||||
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
|
||||
@@ -32,26 +33,16 @@ const request = axios.create({
|
||||
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 自动刷新 ──────────────────────────────
|
||||
// ── 响应拦截器:401 自动刷新 cookie ──────────────────────
|
||||
|
||||
let isRefreshing = false
|
||||
let pendingRequests: Array<{
|
||||
resolve: (token: string) => void
|
||||
resolve: (value: unknown) => void
|
||||
reject: (error: unknown) => void
|
||||
}> = []
|
||||
|
||||
function onTokenRefreshed(newToken: string) {
|
||||
pendingRequests.forEach(({ resolve }) => resolve(newToken))
|
||||
function onTokenRefreshed() {
|
||||
pendingRequests.forEach(({ resolve }) => resolve(undefined))
|
||||
pendingRequests = []
|
||||
}
|
||||
|
||||
@@ -65,10 +56,10 @@ request.interceptors.response.use(
|
||||
async (error: AxiosError<{ error?: string; message?: string }>) => {
|
||||
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
// 401 → 尝试刷新 Token
|
||||
// 401 -> 尝试刷新 cookie
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
const store = useAuthStore.getState()
|
||||
if (!store.refreshToken) {
|
||||
if (!store.isAuthenticated) {
|
||||
store.logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
@@ -77,10 +68,7 @@ request.interceptors.response.use(
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
pendingRequests.push({
|
||||
resolve: (newToken: string) => {
|
||||
originalRequest.headers.Authorization = `Bearer ${newToken}`
|
||||
resolve(request(originalRequest))
|
||||
},
|
||||
resolve: () => resolve(request(originalRequest)),
|
||||
reject,
|
||||
})
|
||||
})
|
||||
@@ -90,22 +78,15 @@ request.interceptors.response.use(
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
|
||||
headers: { Authorization: `Bearer ${store.refreshToken}` },
|
||||
withCredentials: true, // 发送 refresh cookie
|
||||
// Refresh endpoint uses HttpOnly cookie (sent automatically via withCredentials)
|
||||
await axios.post(`${BASE_URL}/auth/refresh`, null, {
|
||||
withCredentials: true,
|
||||
})
|
||||
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}`
|
||||
// Cookie is refreshed server-side; browser has the new cookie automatically
|
||||
onTokenRefreshed()
|
||||
return request(originalRequest)
|
||||
} catch (refreshError) {
|
||||
// 关键修复:刷新失败时 reject 所有等待中的请求,避免它们永远 hang
|
||||
// Refresh failed — reject all pending requests to prevent hangs
|
||||
onTokenRefreshFailed(refreshError)
|
||||
store.logout()
|
||||
window.location.href = '/login'
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
// ============================================================
|
||||
//
|
||||
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
|
||||
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
|
||||
// 内存中的 token/refreshToken 仅用于 Authorization header fallback(API 客户端兼容)。
|
||||
// account 信息(显示名/角色)存 localStorage 用于页面刷新后恢复 UI。
|
||||
// isAuthenticated 标记用于判断登录状态,不暴露任何 token 到 JS。
|
||||
|
||||
import { create } from 'zustand'
|
||||
import type { AccountPublic } from '@/types'
|
||||
@@ -27,24 +27,23 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
|
||||
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||
|
||||
/** 从 localStorage 恢复 account 信息(token 通过 HttpOnly cookie 管理) */
|
||||
function loadFromStorage(): { account: AccountPublic | null } {
|
||||
function loadFromStorage(): { account: AccountPublic | null; isAuthenticated: boolean } {
|
||||
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||
let account: AccountPublic | null = null
|
||||
if (raw) {
|
||||
try { account = JSON.parse(raw) } catch { /* ignore */ }
|
||||
}
|
||||
return { account }
|
||||
// If account exists in localStorage, mark as authenticated (cookie validation
|
||||
// happens in AuthGuard via GET /auth/me — this is just a UI hint)
|
||||
return { account, isAuthenticated: account !== null }
|
||||
}
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
refreshToken: string | null
|
||||
isAuthenticated: boolean
|
||||
account: AccountPublic | null
|
||||
permissions: string[]
|
||||
|
||||
setToken: (token: string) => void
|
||||
setRefreshToken: (refreshToken: string) => void
|
||||
login: (token: string, refreshToken: string, account: AccountPublic) => void
|
||||
login: (account: AccountPublic) => void
|
||||
logout: () => void
|
||||
hasPermission: (permission: string) => boolean
|
||||
}
|
||||
@@ -56,26 +55,15 @@ export const useAuthStore = create<AuthState>((set, get) => {
|
||||
: []
|
||||
|
||||
return {
|
||||
token: null,
|
||||
refreshToken: null,
|
||||
isAuthenticated: stored.isAuthenticated,
|
||||
account: stored.account,
|
||||
permissions: perms,
|
||||
|
||||
setToken: (token: string) => {
|
||||
set({ token })
|
||||
},
|
||||
|
||||
setRefreshToken: (refreshToken: string) => {
|
||||
set({ refreshToken })
|
||||
},
|
||||
|
||||
login: (token: string, refreshToken: string, account: AccountPublic) => {
|
||||
login: (account: AccountPublic) => {
|
||||
// account 保留 localStorage(仅用于 UI 显示,非敏感)
|
||||
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||
// token 仅存内存(实际认证通过 HttpOnly cookie)
|
||||
set({
|
||||
token,
|
||||
refreshToken,
|
||||
isAuthenticated: true,
|
||||
account,
|
||||
permissions: ROLE_PERMISSIONS[account.role] ?? [],
|
||||
})
|
||||
@@ -83,7 +71,7 @@ export const useAuthStore = create<AuthState>((set, get) => {
|
||||
|
||||
logout: () => {
|
||||
localStorage.removeItem(ACCOUNT_KEY)
|
||||
set({ token: null, refreshToken: null, account: null, permissions: [] })
|
||||
set({ isAuthenticated: false, account: null, permissions: [] })
|
||||
// 调用后端 logout 清除 HttpOnly cookies(fire-and-forget)
|
||||
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
|
||||
},
|
||||
|
||||
@@ -23,10 +23,8 @@ export interface LoginRequest {
|
||||
totp_code?: string
|
||||
}
|
||||
|
||||
/** 登录响应 */
|
||||
/** 登录响应 — tokens 通过 HttpOnly cookie 传递,JS 无法读取 */
|
||||
export interface LoginResponse {
|
||||
token: string
|
||||
refresh_token: string
|
||||
account: AccountPublic
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user