feat(M2): Authentication, Authorization & Frontend Shell
- pm-auth::password: Argon2id (m=65536,t=3,p=1) hashing + verification - pm-auth::jwt: EdDSA/Ed25519 JWT issuance + validation (15-min TTL) - pm-auth::refresh: Opaque 256-bit refresh tokens, SHA-256 hashed, 1-hour sliding inactivity timeout, rotation on use, revocable - pm-auth::mfa_totp: TOTP setup/verify (HMAC-SHA1, 6-digit, 30s) with otpauth:// URI generation (Google Authenticator compatible) - pm-auth::mfa_webauthn: Stub (full implementation deferred) - pm-auth::rbac: Axum middleware for JWT auth + IP whitelist + admin/operator role enforcement + FromRequestParts extractor - pm-auth::session: Full login flow (password → MFA → tokens), token refresh, logout, force-logout - pm-web auth routes: POST /api/v1/auth/login|refresh|logout, GET /api/v1/auth/mfa/setup, POST /api/v1/auth/mfa/verify - IP whitelist middleware on all protected connection points - migrations/002_seed_admin.sql: Default admin account seed - Frontend: Auth store (Zustand with persistence), login page with MFA prompt, MFA setup page (stepper), JWT auto-refresh interceptor, route guards (RequireAuth), updated App.tsx routing - cargo check --workspace: zero errors, 1 minor warning Closes M2.
This commit is contained in:
@ -1,8 +1,95 @@
|
||||
import axios from 'axios'
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import type { InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
|
||||
// Base API client — JWT interceptors added in M2
|
||||
export const apiClient = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
baseURL: BASE_URL,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
timeout: 30_000,
|
||||
})
|
||||
|
||||
// ── Request interceptor: attach access token ────────────────────────────────
|
||||
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
if (token && config.headers) {
|
||||
config.headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
return config
|
||||
})
|
||||
|
||||
// ── Response interceptor: refresh on 401 ────────────────────────────────────
|
||||
let isRefreshing = false
|
||||
let failedQueue: Array<{ resolve: (v: string) => void; reject: (e: unknown) => void }> = []
|
||||
|
||||
const processQueue = (error: unknown, token: string | null) => {
|
||||
failedQueue.forEach(({ resolve, reject }) => {
|
||||
if (error) reject(error)
|
||||
else resolve(token!)
|
||||
})
|
||||
failedQueue = []
|
||||
}
|
||||
|
||||
apiClient.interceptors.response.use(
|
||||
(res) => res,
|
||||
async (error: AxiosError) => {
|
||||
const original = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
|
||||
|
||||
if (error.response?.status !== 401 || original._retry) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
if (isRefreshing) {
|
||||
return new Promise((resolve, reject) => {
|
||||
failedQueue.push({ resolve, reject })
|
||||
}).then((token) => {
|
||||
original.headers.Authorization = `Bearer ${token}`
|
||||
return apiClient(original)
|
||||
})
|
||||
}
|
||||
|
||||
original._retry = true
|
||||
isRefreshing = true
|
||||
|
||||
const { refreshToken, setTokens, logout } = useAuthStore.getState()
|
||||
|
||||
if (!refreshToken) {
|
||||
logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
try {
|
||||
const { data } = await axios.post(`${BASE_URL}/auth/refresh`, {
|
||||
refresh_token: refreshToken,
|
||||
})
|
||||
setTokens(data.access_token, data.refresh_token)
|
||||
processQueue(null, data.access_token)
|
||||
original.headers.Authorization = `Bearer ${data.access_token}`
|
||||
return apiClient(original)
|
||||
} catch (refreshError) {
|
||||
processQueue(refreshError, null)
|
||||
logout()
|
||||
window.location.href = '/login'
|
||||
return Promise.reject(refreshError)
|
||||
} finally {
|
||||
isRefreshing = false
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// ── Auth API functions ───────────────────────────────────────────────────────
|
||||
export const authApi = {
|
||||
login: (username: string, password: string, totpCode?: string) =>
|
||||
apiClient.post('/auth/login', { username, password, totp_code: totpCode }),
|
||||
|
||||
logout: (refreshToken: string) =>
|
||||
apiClient.post('/auth/logout', { refresh_token: refreshToken }),
|
||||
|
||||
getMfaSetup: () =>
|
||||
apiClient.get('/auth/mfa/setup'),
|
||||
|
||||
verifyMfa: (secretBase32: string, code: string) =>
|
||||
apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user