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,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>
|
||||
|
||||
@ -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 }),
|
||||
}
|
||||
|
||||
97
frontend/src/pages/LoginPage.tsx
Normal file
97
frontend/src/pages/LoginPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
86
frontend/src/pages/MfaSetupPage.tsx
Normal file
86
frontend/src/pages/MfaSetupPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
37
frontend/src/store/authStore.ts
Normal file
37
frontend/src/store/authStore.ts
Normal 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 }),
|
||||
}
|
||||
)
|
||||
)
|
||||
Reference in New Issue
Block a user