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

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