Private
Public Access
1
0

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:
2026-04-23 16:10:08 +00:00
parent da5a94d838
commit 6811f84a7c
22 changed files with 2014 additions and 87 deletions

View File

@ -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 }),
}