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,11 @@
import { Routes, Route, Navigate } from 'react-router-dom'
import { CssBaseline, ThemeProvider } from '@mui/material'
import { lightTheme } from './theme/theme'
import { useAuthStore } from './store/authStore'
import LoginPage from './pages/LoginPage'
import MfaSetupPage from './pages/MfaSetupPage'
// Placeholder pages — implemented in M2+
// Placeholder pages — implemented in M3+
const PlaceholderPage = ({ title }: { title: string }) => (
<div style={{ padding: 32 }}>
<h2>{title}</h2>
@ -10,24 +13,36 @@ const PlaceholderPage = ({ title }: { title: string }) => (
</div>
)
// Guard component: redirects to /login if not authenticated
function RequireAuth({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
return isAuthenticated ? <>{children}</> : <Navigate to="/login" replace />
}
function App() {
return (
<ThemeProvider theme={lightTheme}>
<CssBaseline />
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<PlaceholderPage title="Dashboard" />} />
<Route path="/hosts" element={<PlaceholderPage title="Hosts" />} />
<Route path="/hosts/:id" element={<PlaceholderPage title="Host Detail" />} />
<Route path="/jobs" element={<PlaceholderPage title="Jobs" />} />
<Route path="/deployment" element={<PlaceholderPage title="Patch Deployment" />} />
<Route path="/maintenance" element={<PlaceholderPage title="Maintenance Windows" />} />
<Route path="/groups" element={<PlaceholderPage title="Groups" />} />
<Route path="/reports" element={<PlaceholderPage title="Reports" />} />
<Route path="/users" element={<PlaceholderPage title="Users" />} />
<Route path="/certificates" element={<PlaceholderPage title="Certificates" />} />
<Route path="/settings" element={<PlaceholderPage title="Settings" />} />
<Route path="/login" element={<PlaceholderPage title="Login" />} />
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
{/* Protected routes */}
<Route path="/" element={<RequireAuth><Navigate to="/dashboard" replace /></RequireAuth>} />
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
<Route path="/hosts" element={<RequireAuth><PlaceholderPage title="Hosts" /></RequireAuth>} />
<Route path="/hosts/:id" element={<RequireAuth><PlaceholderPage title="Host Detail" /></RequireAuth>} />
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
<Route path="/groups" element={<RequireAuth><PlaceholderPage title="Groups" /></RequireAuth>} />
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
<Route path="/users" element={<RequireAuth><PlaceholderPage title="Users" /></RequireAuth>} />
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
{/* 404 */}
<Route path="*" element={<PlaceholderPage title="404 Not Found" />} />
</Routes>
</ThemeProvider>

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

View File

@ -0,0 +1,97 @@
import React, { useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, InputAdornment, IconButton,
} from '@mui/material'
import { Visibility, VisibilityOff } from '@mui/icons-material'
import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
import type { User } from '../types'
export default function LoginPage() {
const navigate = useNavigate()
const { setTokens, setUser } = useAuthStore()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [needsMfa, setNeedsMfa] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setLoading(true)
setError(null)
try {
const res = await authApi.login(username, password, needsMfa ? totpCode : undefined)
const { access_token, refresh_token, user } = res.data
setTokens(access_token, refresh_token)
setUser(user as User)
navigate('/dashboard', { replace: true })
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: { code?: string; message?: string } } } }
const code = e.response?.data?.error?.code
if (code === 'mfa_required') {
setNeedsMfa(true)
setError('Please enter your MFA code.')
} else {
setError(e.response?.data?.error?.message || 'Login failed')
}
} finally {
setLoading(false)
}
}
return (
<Container maxWidth="xs" sx={{ mt: 12 }}>
<Paper elevation={4} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3} align="center">
Linux Patch Manager
</Typography>
{error && <Alert severity={needsMfa && error.startsWith('Please') ? 'info' : 'error'} sx={{ mb: 2 }}>{error}</Alert>}
<Box component="form" onSubmit={handleSubmit} noValidate>
<TextField
fullWidth margin="normal" label="Username" autoComplete="username"
value={username} onChange={(e) => setUsername(e.target.value)}
disabled={loading} required
/>
<TextField
fullWidth margin="normal" label="Password" type={showPassword ? 'text' : 'password'}
autoComplete="current-password" value={password}
onChange={(e) => setPassword(e.target.value)} disabled={loading} required
InputProps={{
endAdornment: (
<InputAdornment position="end">
<IconButton onClick={() => setShowPassword(!showPassword)} edge="end">
{showPassword ? <VisibilityOff /> : <Visibility />}
</IconButton>
</InputAdornment>
),
}}
/>
{needsMfa && (
<TextField
fullWidth margin="normal" label="MFA Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={totpCode} onChange={(e) => setTotpCode(e.target.value)}
disabled={loading} required autoFocus
helperText="Enter the 6-digit code from your authenticator app"
/>
)}
<Button
type="submit" fullWidth variant="contained" size="large"
sx={{ mt: 3 }} disabled={loading}
>
{loading ? <CircularProgress size={24} /> : 'Sign In'}
</Button>
</Box>
</Paper>
</Container>
)
}

View File

@ -0,0 +1,86 @@
import React, { useEffect, useState } from 'react'
import {
Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
} from '@mui/material'
import { authApi } from '../api/client'
const STEPS = ['Get your QR code', 'Verify code', 'Done']
export default function MfaSetupPage() {
const [step, setStep] = useState(0)
const [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null)
const [code, setCode] = useState('')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
authApi.getMfaSetup()
.then((res) => setSetup(res.data))
.catch(() => setError('Failed to load MFA setup.'))
}, [])
const handleVerify = async (e: React.FormEvent) => {
e.preventDefault()
if (!setup) return
setLoading(true)
setError(null)
try {
await authApi.verifyMfa(setup.secret_base32, code)
setStep(2)
} catch {
setError('Invalid code. Please try again.')
} finally {
setLoading(false)
}
}
return (
<Container maxWidth="sm" sx={{ mt: 6 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
<Stepper activeStep={step} sx={{ mb: 4 }}>
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{step === 0 && setup && (
<Box>
<Typography mb={2}>
Scan this URI in your authenticator app or enter the secret manually:
</Typography>
<Typography variant="body2" sx={{ fontFamily: 'monospace', wordBreak: 'break-all', mb: 2, p: 1, bgcolor: 'grey.100', borderRadius: 1 }}>
{setup.otp_uri}
</Typography>
<Typography variant="caption" color="text.secondary" display="block" mb={3}>
Manual entry secret: <strong>{setup.secret_base32}</strong>
</Typography>
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
</Box>
)}
{step === 1 && (
<Box component="form" onSubmit={handleVerify}>
<Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography>
<TextField
fullWidth label="Verification Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={code} onChange={(e) => setCode(e.target.value)}
disabled={loading} required autoFocus
/>
<Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'}
</Button>
</Box>
)}
{step === 2 && (
<Alert severity="success">
MFA has been enabled for your account. You will need your authenticator app at each login.
</Alert>
)}
</Paper>
</Container>
)
}

View File

@ -0,0 +1,37 @@
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
import type { User } from '../types'
interface AuthState {
accessToken: string | null
refreshToken: string | null
user: User | null
isAuthenticated: boolean
setTokens: (access: string, refresh: string) => void
setUser: (user: User) => void
logout: () => void
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
accessToken: null,
refreshToken: null,
user: null,
isAuthenticated: false,
setTokens: (access, refresh) =>
set({ accessToken: access, refreshToken: refresh, isAuthenticated: true }),
setUser: (user) => set({ user }),
logout: () =>
set({ accessToken: null, refreshToken: null, user: null, isAuthenticated: false }),
}),
{
name: 'pm-auth',
// Only persist refresh token; access token regenerated on load
partialize: (state) => ({ refreshToken: state.refreshToken, user: state.user }),
}
)
)