feat: implement 5 QA-driven security and quality proposals
Implemented proposals from comprehensive QA review: 1. extend-csrf-protection - Add POST to CSRF protected methods in frontend - Global CSRF middleware for all state-changing operations - Update tests with CSRF token fixtures 2. tighten-cors-websocket-security - Replace wildcard CORS with explicit method/header lists - Disable query parameter auth in production (code 4002) - Add per-user WebSocket connection limit (max 5, code 4005) 3. shorten-jwt-expiry - Reduce JWT expiry from 7 days to 60 minutes - Add refresh token support with 7-day expiry - Implement token rotation on refresh - Frontend auto-refresh when token near expiry (<5 min) 4. fix-frontend-quality - Add React.lazy() code splitting for all pages - Fix useCallback dependency arrays (Dashboard, Comments) - Add localStorage data validation in AuthContext - Complete i18n for AttachmentUpload component 5. enhance-backend-validation - Add SecurityAuditMiddleware for access denied logging - Add ErrorSanitizerMiddleware for production error messages - Protect /health/detailed with admin authentication - Add input length validation (comment 5000, desc 10000) All 521 backend tests passing. Frontend builds successfully. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,8 @@
|
||||
"emailRequired": "Email is required",
|
||||
"passwordRequired": "Password is required",
|
||||
"invalidEmail": "Please enter a valid email address",
|
||||
"loginFailed": "Login failed. Please try again later."
|
||||
"loginFailed": "Login failed. Please try again later.",
|
||||
"sessionExpired": "Your session has expired. Please sign in again."
|
||||
},
|
||||
"welcome": {
|
||||
"title": "Project Control Center",
|
||||
|
||||
@@ -129,5 +129,11 @@
|
||||
"message": "Unable to display this widget.",
|
||||
"errorSuffix": "error"
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"dropzone": "Drop files here or click to upload",
|
||||
"maxFileSize": "Maximum file size: {{size}}",
|
||||
"uploading": "Uploading {{filename}} ({{current}}/{{total}})...",
|
||||
"uploadFailed": "Upload failed"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,8 @@
|
||||
"emailRequired": "請輸入電子郵件",
|
||||
"passwordRequired": "請輸入密碼",
|
||||
"invalidEmail": "請輸入有效的電子郵件地址",
|
||||
"loginFailed": "登入失敗,請稍後再試"
|
||||
"loginFailed": "登入失敗,請稍後再試",
|
||||
"sessionExpired": "您的登入時段已過期,請重新登入。"
|
||||
},
|
||||
"welcome": {
|
||||
"title": "專案控制中心",
|
||||
|
||||
@@ -129,5 +129,11 @@
|
||||
"message": "無法顯示此元件。",
|
||||
"errorSuffix": "發生錯誤"
|
||||
}
|
||||
},
|
||||
"attachments": {
|
||||
"dropzone": "拖曳檔案至此或點擊上傳",
|
||||
"maxFileSize": "檔案大小上限:{{size}}",
|
||||
"uploading": "正在上傳 {{filename}} ({{current}}/{{total}})...",
|
||||
"uploadFailed": "上傳失敗"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,21 +1,35 @@
|
||||
import { lazy, Suspense } from 'react'
|
||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from './contexts/AuthContext'
|
||||
import { Skeleton } from './components/Skeleton'
|
||||
import { ErrorBoundary } from './components/ErrorBoundary'
|
||||
import { SectionErrorBoundary } from './components/ErrorBoundaryWithI18n'
|
||||
import Login from './pages/Login'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import Spaces from './pages/Spaces'
|
||||
import Projects from './pages/Projects'
|
||||
import Tasks from './pages/Tasks'
|
||||
import ProjectSettings from './pages/ProjectSettings'
|
||||
import MySettings from './pages/MySettings'
|
||||
import AuditPage from './pages/AuditPage'
|
||||
import WorkloadPage from './pages/WorkloadPage'
|
||||
import ProjectHealthPage from './pages/ProjectHealthPage'
|
||||
import ProtectedRoute from './components/ProtectedRoute'
|
||||
import Layout from './components/Layout'
|
||||
|
||||
// Lazy load pages for code splitting
|
||||
const Login = lazy(() => import('./pages/Login'))
|
||||
const Dashboard = lazy(() => import('./pages/Dashboard'))
|
||||
const Spaces = lazy(() => import('./pages/Spaces'))
|
||||
const Projects = lazy(() => import('./pages/Projects'))
|
||||
const Tasks = lazy(() => import('./pages/Tasks'))
|
||||
const ProjectSettings = lazy(() => import('./pages/ProjectSettings'))
|
||||
const MySettings = lazy(() => import('./pages/MySettings'))
|
||||
const AuditPage = lazy(() => import('./pages/AuditPage'))
|
||||
const WorkloadPage = lazy(() => import('./pages/WorkloadPage'))
|
||||
const ProjectHealthPage = lazy(() => import('./pages/ProjectHealthPage'))
|
||||
|
||||
// Loading fallback component for Suspense
|
||||
function PageLoadingFallback() {
|
||||
return (
|
||||
<div className="container" style={{ padding: '24px', maxWidth: '1200px', margin: '0 auto' }}>
|
||||
<Skeleton variant="text" width={200} height={32} style={{ marginBottom: '16px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={200} style={{ marginBottom: '16px' }} />
|
||||
<Skeleton variant="rect" width="100%" height={150} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
const { isAuthenticated, loading } = useAuth()
|
||||
|
||||
@@ -30,120 +44,122 @@ function App() {
|
||||
|
||||
return (
|
||||
<ErrorBoundary variant="page">
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Dashboard">
|
||||
<Dashboard />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Spaces">
|
||||
<Spaces />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces/:spaceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Projects">
|
||||
<Projects />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Tasks">
|
||||
<Tasks />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Project Settings">
|
||||
<ProjectSettings />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Audit">
|
||||
<AuditPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workload"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Workload">
|
||||
<WorkloadPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/project-health"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Project Health">
|
||||
<ProjectHealthPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Settings">
|
||||
<MySettings />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
<Suspense fallback={<PageLoadingFallback />}>
|
||||
<Routes>
|
||||
<Route
|
||||
path="/login"
|
||||
element={isAuthenticated ? <Navigate to="/" /> : <Login />}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Dashboard">
|
||||
<Dashboard />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Spaces">
|
||||
<Spaces />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/spaces/:spaceId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Projects">
|
||||
<Projects />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Tasks">
|
||||
<Tasks />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/projects/:projectId/settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Project Settings">
|
||||
<ProjectSettings />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/audit"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Audit">
|
||||
<AuditPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/workload"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Workload">
|
||||
<WorkloadPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/project-health"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Project Health">
|
||||
<ProjectHealthPage />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/my-settings"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Layout>
|
||||
<SectionErrorBoundary sectionName="Settings">
|
||||
<MySettings />
|
||||
</SectionErrorBoundary>
|
||||
</Layout>
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</ErrorBoundary>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useRef, useEffect, DragEvent, ChangeEvent } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { attachmentService } from '../services/attachments'
|
||||
|
||||
// Spinner animation keyframes - injected once via useEffect
|
||||
@@ -10,6 +11,7 @@ interface AttachmentUploadProps {
|
||||
}
|
||||
|
||||
export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadProps) {
|
||||
const { t } = useTranslation('common')
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [uploadProgress, setUploadProgress] = useState<string | null>(null)
|
||||
@@ -79,14 +81,20 @@ export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadP
|
||||
try {
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
const file = files[i]
|
||||
setUploadProgress(`Uploading ${file.name} (${i + 1}/${files.length})...`)
|
||||
setUploadProgress(
|
||||
t('attachments.uploading', {
|
||||
filename: file.name,
|
||||
current: i + 1,
|
||||
total: files.length,
|
||||
})
|
||||
)
|
||||
await attachmentService.uploadAttachment(taskId, file)
|
||||
}
|
||||
setUploadProgress(null)
|
||||
onUploadComplete?.()
|
||||
} catch (err: unknown) {
|
||||
console.error('Upload failed:', err)
|
||||
const errorMessage = err instanceof Error ? err.message : 'Upload failed'
|
||||
const errorMessage = err instanceof Error ? err.message : t('attachments.uploadFailed')
|
||||
setError(errorMessage)
|
||||
} finally {
|
||||
setUploading(false)
|
||||
@@ -127,10 +135,10 @@ export function AttachmentUpload({ taskId, onUploadComplete }: AttachmentUploadP
|
||||
<div style={styles.content}>
|
||||
<span style={styles.icon}>📎</span>
|
||||
<span style={styles.text}>
|
||||
Drop files here or click to upload
|
||||
{t('attachments.dropzone')}
|
||||
</span>
|
||||
<span style={styles.hint}>
|
||||
Maximum file size: 50MB
|
||||
{t('attachments.maxFileSize', { size: '50MB' })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -35,7 +35,7 @@ export function Comments({ taskId }: CommentsProps) {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [taskId])
|
||||
}, [taskId, t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchComments()
|
||||
|
||||
@@ -1,5 +1,58 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react'
|
||||
import { authApi, User, LoginRequest } from '../services/api'
|
||||
import {
|
||||
authApi,
|
||||
User,
|
||||
LoginRequest,
|
||||
storeTokens,
|
||||
clearStoredTokens,
|
||||
getStoredToken,
|
||||
isTokenExpired,
|
||||
} from '../services/api'
|
||||
|
||||
/**
|
||||
* Validates that a parsed object has the required User properties.
|
||||
* Returns the validated User object or null if validation fails.
|
||||
*/
|
||||
function validateUserData(data: unknown): User | null {
|
||||
// Check if data is an object
|
||||
if (!data || typeof data !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const obj = data as Record<string, unknown>
|
||||
|
||||
// Validate required string fields
|
||||
if (typeof obj.id !== 'string' || obj.id.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (typeof obj.email !== 'string' || obj.email.length === 0) {
|
||||
return null
|
||||
}
|
||||
if (typeof obj.name !== 'string' || obj.name.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate optional/nullable fields
|
||||
if (obj.role !== null && typeof obj.role !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (obj.department_id !== null && typeof obj.department_id !== 'string') {
|
||||
return null
|
||||
}
|
||||
if (typeof obj.is_system_admin !== 'boolean') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Return validated user object
|
||||
return {
|
||||
id: obj.id,
|
||||
email: obj.email,
|
||||
name: obj.name,
|
||||
role: obj.role as string | null,
|
||||
department_id: obj.department_id as string | null,
|
||||
is_system_admin: obj.is_system_admin,
|
||||
}
|
||||
}
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null
|
||||
@@ -17,15 +70,35 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
useEffect(() => {
|
||||
// Check for existing token on mount
|
||||
const token = localStorage.getItem('token')
|
||||
const token = getStoredToken()
|
||||
const storedUser = localStorage.getItem('user')
|
||||
|
||||
if (token && storedUser) {
|
||||
try {
|
||||
setUser(JSON.parse(storedUser))
|
||||
} catch {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// Check if token is expired
|
||||
if (isTokenExpired(token)) {
|
||||
// Token is expired, clear storage and don't restore user
|
||||
// The refresh will happen automatically on next API call if refresh token exists
|
||||
clearStoredTokens()
|
||||
} else {
|
||||
// Parse and validate stored user data
|
||||
const parsedUser = JSON.parse(storedUser)
|
||||
const validatedUser = validateUserData(parsedUser)
|
||||
|
||||
if (validatedUser) {
|
||||
setUser(validatedUser)
|
||||
} else {
|
||||
// Invalid user data structure, clear storage and redirect to login
|
||||
console.warn('Invalid user data in localStorage, clearing session')
|
||||
clearStoredTokens()
|
||||
// Don't redirect here as we're in initial loading state
|
||||
// The app will naturally show login page when user is null
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// JSON parse error or other unexpected error
|
||||
console.error('Error parsing stored user data:', err)
|
||||
clearStoredTokens()
|
||||
}
|
||||
}
|
||||
setLoading(false)
|
||||
@@ -33,7 +106,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const login = async (data: LoginRequest) => {
|
||||
const response = await authApi.login(data)
|
||||
localStorage.setItem('token', response.access_token)
|
||||
// Store access token and refresh token (if provided by backend)
|
||||
storeTokens(response.access_token, response.refresh_token)
|
||||
localStorage.setItem('user', JSON.stringify(response.user))
|
||||
setUser(response.user)
|
||||
}
|
||||
@@ -44,8 +118,8 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
} catch {
|
||||
// Ignore errors on logout
|
||||
} finally {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
// Clear all tokens (access, refresh, and user data)
|
||||
clearStoredTokens()
|
||||
setUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,7 +29,7 @@ export default function Dashboard() {
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
fetchDashboard()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useState, useEffect, FormEvent } from 'react'
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
import { LanguageSwitcher } from '../components/LanguageSwitcher'
|
||||
@@ -9,9 +9,21 @@ export default function Login() {
|
||||
const [email, setEmail] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [info, setInfo] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
|
||||
// Check for session expired redirect
|
||||
useEffect(() => {
|
||||
const reason = searchParams.get('reason')
|
||||
if (reason === 'session_expired') {
|
||||
setInfo(t('errors.sessionExpired'))
|
||||
// Clean up the URL by removing the query parameter
|
||||
setSearchParams({}, { replace: true })
|
||||
}
|
||||
}, [searchParams, setSearchParams, t])
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault()
|
||||
@@ -45,6 +57,7 @@ export default function Login() {
|
||||
<p style={styles.subtitle}>{t('login.subtitle')}</p>
|
||||
|
||||
<form onSubmit={handleSubmit} style={styles.form}>
|
||||
{info && <div style={styles.info}>{info}</div>}
|
||||
{error && <div style={styles.error}>{error}</div>}
|
||||
|
||||
<div style={styles.field}>
|
||||
@@ -163,4 +176,11 @@ const styles: { [key: string]: React.CSSProperties } = {
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
info: {
|
||||
backgroundColor: '#e6f4ff',
|
||||
color: '#0066cc',
|
||||
padding: '10px',
|
||||
borderRadius: '4px',
|
||||
fontSize: '14px',
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import axios, { InternalAxiosRequestConfig } from 'axios'
|
||||
import axios, { InternalAxiosRequestConfig, AxiosError } from 'axios'
|
||||
|
||||
// API base URL - using legacy routes until v1 migration is complete
|
||||
// TODO: Switch to /api/v1 when all routes are migrated
|
||||
@@ -9,10 +9,141 @@ const API_BASE_URL = '/api'
|
||||
let csrfToken: string | null = null
|
||||
let csrfTokenExpiry: number | null = null
|
||||
const CSRF_TOKEN_HEADER = 'X-CSRF-Token'
|
||||
const CSRF_PROTECTED_METHODS = ['DELETE', 'PUT', 'PATCH']
|
||||
const CSRF_PROTECTED_METHODS = ['POST', 'DELETE', 'PUT', 'PATCH']
|
||||
// Token expires in 1 hour, refresh 5 minutes before expiry
|
||||
const CSRF_TOKEN_LIFETIME_MS = 55 * 60 * 1000
|
||||
|
||||
// JWT Token refresh configuration
|
||||
// Access tokens expire in 60 minutes, refresh 5 minutes before expiry
|
||||
const TOKEN_REFRESH_THRESHOLD_MS = 5 * 60 * 1000
|
||||
|
||||
// Token refresh state management
|
||||
let isRefreshing = false
|
||||
let refreshSubscribers: Array<(token: string) => void> = []
|
||||
|
||||
/**
|
||||
* JWT Token Utilities
|
||||
*/
|
||||
|
||||
/**
|
||||
* Decode a JWT token payload without verification.
|
||||
* Note: This is for reading claims only, not for security validation.
|
||||
* Security validation happens on the backend.
|
||||
*/
|
||||
export function decodeJwtPayload(token: string): JwtPayload | null {
|
||||
try {
|
||||
const parts = token.split('.')
|
||||
if (parts.length !== 3) {
|
||||
return null
|
||||
}
|
||||
// Decode base64url to base64
|
||||
const base64 = parts[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
// Add padding if needed
|
||||
const padded = base64 + '='.repeat((4 - (base64.length % 4)) % 4)
|
||||
const decoded = atob(padded)
|
||||
return JSON.parse(decoded)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the expiration time (in milliseconds since epoch) from a JWT token.
|
||||
*/
|
||||
export function getTokenExpiryTime(token: string): number | null {
|
||||
const payload = decodeJwtPayload(token)
|
||||
if (!payload || typeof payload.exp !== 'number') {
|
||||
return null
|
||||
}
|
||||
// JWT exp is in seconds, convert to milliseconds
|
||||
return payload.exp * 1000
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is about to expire (within threshold).
|
||||
* Returns true if token will expire within the threshold or has already expired.
|
||||
*/
|
||||
export function isTokenExpiringSoon(
|
||||
token: string,
|
||||
thresholdMs: number = TOKEN_REFRESH_THRESHOLD_MS
|
||||
): boolean {
|
||||
const expiryTime = getTokenExpiryTime(token)
|
||||
if (expiryTime === null) {
|
||||
// If we can't determine expiry, assume it needs refresh
|
||||
return true
|
||||
}
|
||||
return Date.now() >= expiryTime - thresholdMs
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token has already expired.
|
||||
*/
|
||||
export function isTokenExpired(token: string): boolean {
|
||||
const expiryTime = getTokenExpiryTime(token)
|
||||
if (expiryTime === null) {
|
||||
return true
|
||||
}
|
||||
return Date.now() >= expiryTime
|
||||
}
|
||||
|
||||
interface JwtPayload {
|
||||
sub: string
|
||||
email: string
|
||||
role?: string | null
|
||||
department_id?: string | null
|
||||
is_system_admin?: boolean
|
||||
exp: number
|
||||
iat: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Token Storage Utilities
|
||||
* Note: Using localStorage for token storage. While httpOnly cookies are more
|
||||
* secure against XSS attacks, localStorage is acceptable for this implementation
|
||||
* as long as proper XSS protections are in place (Content Security Policy, etc.).
|
||||
* The refresh token mechanism limits exposure time if a token is compromised.
|
||||
*/
|
||||
const TOKEN_KEY = 'token'
|
||||
const REFRESH_TOKEN_KEY = 'refresh_token'
|
||||
const USER_KEY = 'user'
|
||||
|
||||
export function getStoredToken(): string | null {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getStoredRefreshToken(): string | null {
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function storeTokens(accessToken: string, refreshToken?: string): void {
|
||||
localStorage.setItem(TOKEN_KEY, accessToken)
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken)
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStoredTokens(): void {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to token refresh completion.
|
||||
* Used to queue requests while a refresh is in progress.
|
||||
*/
|
||||
function subscribeToTokenRefresh(callback: (token: string) => void): void {
|
||||
refreshSubscribers.push(callback)
|
||||
}
|
||||
|
||||
/**
|
||||
* Notify all subscribers that token has been refreshed.
|
||||
*/
|
||||
function onTokenRefreshed(newToken: string): void {
|
||||
refreshSubscribers.forEach((callback) => callback(newToken))
|
||||
refreshSubscribers = []
|
||||
}
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: API_BASE_URL,
|
||||
headers: {
|
||||
@@ -77,33 +208,149 @@ export async function prefetchCsrfToken(): Promise<void> {
|
||||
await fetchCsrfToken()
|
||||
}
|
||||
|
||||
// Add token to requests and CSRF token for protected methods
|
||||
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
/**
|
||||
* Refresh the access token using the refresh token.
|
||||
* This is called automatically when the access token is about to expire.
|
||||
*
|
||||
* @returns The new access token, or null if refresh failed
|
||||
*/
|
||||
async function refreshAccessToken(): Promise<string | null> {
|
||||
const refreshToken = getStoredRefreshToken()
|
||||
if (!refreshToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Add CSRF token for protected methods
|
||||
const method = config.method?.toUpperCase()
|
||||
if (method && CSRF_PROTECTED_METHODS.includes(method)) {
|
||||
const csrf = await getValidCsrfToken()
|
||||
if (csrf) {
|
||||
config.headers[CSRF_TOKEN_HEADER] = csrf
|
||||
try {
|
||||
// Use axios directly to avoid interceptor loops
|
||||
const response = await axios.post<{
|
||||
access_token: string
|
||||
refresh_token?: string
|
||||
token_type: string
|
||||
}>(
|
||||
`${API_BASE_URL}/auth/refresh`,
|
||||
{ refresh_token: refreshToken },
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const { access_token, refresh_token: newRefreshToken } = response.data
|
||||
|
||||
// Store the new tokens
|
||||
storeTokens(access_token, newRefreshToken || refreshToken)
|
||||
|
||||
return access_token
|
||||
} catch (error) {
|
||||
// If refresh fails (401 or other error), the token is invalid
|
||||
// Clear all tokens and let the response interceptor handle redirect
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure we have a valid access token, refreshing if necessary.
|
||||
* This implements a queue mechanism to prevent multiple simultaneous refresh requests.
|
||||
*
|
||||
* @returns A promise that resolves with a valid token or null if unavailable
|
||||
*/
|
||||
async function ensureValidToken(): Promise<string | null> {
|
||||
const token = getStoredToken()
|
||||
|
||||
if (!token) {
|
||||
return null
|
||||
}
|
||||
|
||||
// If token is not expiring soon, use it as-is
|
||||
if (!isTokenExpiringSoon(token)) {
|
||||
return token
|
||||
}
|
||||
|
||||
// If we're already refreshing, wait for it to complete
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve) => {
|
||||
subscribeToTokenRefresh((newToken) => {
|
||||
resolve(newToken)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Start the refresh process
|
||||
isRefreshing = true
|
||||
|
||||
try {
|
||||
const newToken = await refreshAccessToken()
|
||||
|
||||
if (newToken) {
|
||||
onTokenRefreshed(newToken)
|
||||
return newToken
|
||||
} else {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
clearStoredTokens()
|
||||
clearCsrfToken()
|
||||
// Notify subscribers with empty token (they'll fail but won't retry)
|
||||
refreshSubscribers = []
|
||||
window.location.href = '/login?reason=session_expired'
|
||||
return null
|
||||
}
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add token to requests and CSRF token for protected methods
|
||||
// This interceptor ensures tokens are refreshed before they expire
|
||||
api.interceptors.request.use(async (config: InternalAxiosRequestConfig) => {
|
||||
// Skip token handling for auth endpoints that don't require authentication
|
||||
const isAuthEndpoint =
|
||||
config.url?.includes('/auth/login') || config.url?.includes('/auth/refresh')
|
||||
|
||||
if (!isAuthEndpoint) {
|
||||
// Ensure we have a valid token (will refresh if expiring soon)
|
||||
const token = await ensureValidToken()
|
||||
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
|
||||
// Add CSRF token for protected methods
|
||||
const method = config.method?.toUpperCase()
|
||||
if (method && CSRF_PROTECTED_METHODS.includes(method)) {
|
||||
const csrf = await getValidCsrfToken()
|
||||
if (csrf) {
|
||||
config.headers[CSRF_TOKEN_HEADER] = csrf
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return config
|
||||
})
|
||||
|
||||
// Handle 401 responses
|
||||
// Handle 401 responses - clear tokens and redirect to login
|
||||
// Note: Token refresh is handled proactively in the request interceptor
|
||||
// A 401 here means either:
|
||||
// 1. The token was revoked on the server
|
||||
// 2. The refresh token has expired
|
||||
// 3. Some other authentication issue
|
||||
api.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
(error: AxiosError) => {
|
||||
if (error.response?.status === 401) {
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('user')
|
||||
clearCsrfToken()
|
||||
window.location.href = '/login'
|
||||
// Check if this is from a refresh endpoint to avoid redirect loops
|
||||
const isRefreshRequest = error.config?.url?.includes('/auth/refresh')
|
||||
|
||||
if (!isRefreshRequest) {
|
||||
// Clear all auth state
|
||||
clearStoredTokens()
|
||||
clearCsrfToken()
|
||||
|
||||
// Redirect to login with appropriate message
|
||||
const currentPath = window.location.pathname
|
||||
if (currentPath !== '/login') {
|
||||
window.location.href = '/login?reason=session_expired'
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.reject(error)
|
||||
}
|
||||
@@ -125,10 +372,17 @@ export interface User {
|
||||
|
||||
export interface LoginResponse {
|
||||
access_token: string
|
||||
refresh_token?: string // Optional for backward compatibility during migration
|
||||
token_type: string
|
||||
user: User
|
||||
}
|
||||
|
||||
export interface RefreshTokenResponse {
|
||||
access_token: string
|
||||
refresh_token?: string // New refresh token if rotation is enabled
|
||||
token_type: string
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
login: async (data: LoginRequest): Promise<LoginResponse> => {
|
||||
const response = await api.post<LoginResponse>('/auth/login', data)
|
||||
|
||||
Reference in New Issue
Block a user