Private
Public Access
1
0

Phase 4: Exhaustive analysis fixes, security hardening, and code quality improvements

This commit is contained in:
2026-05-15 22:10:05 +00:00
parent 4593458c5a
commit 7b067b2813
4 changed files with 499 additions and 98 deletions

View File

@ -121,6 +121,25 @@ export const authApi = {
verifyMfa: (secretBase32: string, code: string) => verifyMfa: (secretBase32: string, code: string) =>
apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }), apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }),
// WebAuthn MFA stubs
webauthnAuthenticateStart: () =>
apiClient.post('/auth/mfa/webauthn/authenticate/start'),
webauthnAuthenticateComplete: (challengeKey: string, serializedAssertion: unknown) =>
apiClient.post('/auth/mfa/webauthn/authenticate/complete', { challenge_key: challengeKey, serialized_assertion: serializedAssertion }),
webauthnListCredentials: () =>
apiClient.get('/auth/mfa/webauthn/credentials'),
webauthnRegisterStart: (keyName?: string) =>
apiClient.post('/auth/mfa/webauthn/register/start', { key_name: keyName }),
webauthnRegisterComplete: (challengeKey: string, serializedCredential: unknown, keyName?: string) =>
apiClient.post('/auth/mfa/webauthn/register/complete', { challenge_key: challengeKey, serialized_credential: serializedCredential, key_name: keyName }),
webauthnDeleteCredential: (id: string) =>
apiClient.delete(`/auth/mfa/webauthn/credentials/${id}`),
} }
// ── Fleet API functions ────────────────────────────────────────────────────── // ── Fleet API functions ──────────────────────────────────────────────────────

View File

@ -1,8 +1,9 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { import {
Box, Button, Chip, CircularProgress, Container, IconButton, Box, Button, Chip, CircularProgress, Container, Dialog, DialogTitle,
Paper, Table, TableBody, TableCell, TableContainer, TableHead, DialogContent, DialogActions, IconButton, Paper, Snackbar, Alert,
TableRow, TextField, Toolbar, Tooltip, Typography, Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
TablePagination, TextField, Toolbar, Tooltip, Typography,
} from '@mui/material' } from '@mui/material'
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material' import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon, CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom' import { useNavigate } from 'react-router-dom'
@ -19,19 +20,24 @@ export default function HostsPage() {
const canWrite = user?.role === 'admin' || user?.role === 'operator' const canWrite = user?.role === 'admin' || user?.role === 'operator'
const [hosts, setHosts] = useState<Host[]>([]) const [hosts, setHosts] = useState<Host[]>([])
const [total, setTotal] = useState(0) const [total, setTotal] = useState(0)
const [page, setPage] = useState(0)
const [rowsPerPage, setRowsPerPage] = useState(25)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState<string | null>(null) const [refreshing, setRefreshing] = useState<string | null>(null)
const [deleteTarget, setDeleteTarget] = useState<Host | null>(null)
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success' })
const load = useCallback(async () => { const load = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const res = await apiClient.get('/hosts', { params: { limit: 100 } }) const offset = page * rowsPerPage
const res = await apiClient.get('/hosts', { params: { limit: rowsPerPage, offset } })
setHosts(res.data.hosts) setHosts(res.data.hosts)
setTotal(res.data.total) setTotal(res.data.total)
} catch { /* handled by interceptor */ } } catch { /* handled by interceptor */ }
finally { setLoading(false) } finally { setLoading(false) }
}, []) }, [page, rowsPerPage])
const handleRefresh = async (e: React.MouseEvent, hostId: string) => { const handleRefresh = async (e: React.MouseEvent, hostId: string) => {
e.stopPropagation() e.stopPropagation()
@ -44,13 +50,35 @@ export default function HostsPage() {
} }
} }
useEffect(() => { load() }, []) // eslint-disable-line react-hooks/exhaustive-deps const handleDelete = async () => {
if (!deleteTarget) return
try {
await hostsApi.delete(deleteTarget.id)
setSnackbar({ open: true, message: `Host "${deleteTarget.display_name || deleteTarget.fqdn}" deleted`, severity: 'success' })
load()
} catch {
setSnackbar({ open: true, message: `Failed to delete host "${deleteTarget.display_name || deleteTarget.fqdn}"`, severity: 'error' })
} finally {
setDeleteTarget(null)
}
}
useEffect(() => { load() }, [load])
const filtered = hosts.filter(h => const filtered = hosts.filter(h =>
h.fqdn.toLowerCase().includes(search.toLowerCase()) || h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
h.display_name.toLowerCase().includes(search.toLowerCase()) h.display_name.toLowerCase().includes(search.toLowerCase())
) )
const handleChangePage = (_event: React.MouseEvent<HTMLButtonElement> | null, newPage: number) => {
setPage(newPage)
}
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setRowsPerPage(parseInt(event.target.value, 10))
setPage(0)
}
return ( return (
<Container maxWidth="xl" sx={{ mt: 3 }}> <Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}> <Toolbar disableGutters sx={{ mb: 2 }}>
@ -60,7 +88,7 @@ export default function HostsPage() {
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip> <Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
{canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>} {canWrite && <Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>}
</Toolbar> </Toolbar>
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : ( {loading ? <Box display="flex" justifyContent="center" mt="4"><CircularProgress /></Box> : (
<TableContainer component={Paper}> <TableContainer component={Paper}>
<Table size="small"> <Table size="small">
<TableHead> <TableHead>
@ -106,7 +134,7 @@ export default function HostsPage() {
: <RefreshIcon fontSize="small" />} : <RefreshIcon fontSize="small" />}
</IconButton> </IconButton>
</Tooltip> </Tooltip>
<Tooltip title="Delete"><IconButton size="small" color="error"> <Tooltip title="Delete"><IconButton size="small" color="error" onClick={(e) => { e.stopPropagation(); setDeleteTarget(h) }}>
<DeleteIcon fontSize="small" /> <DeleteIcon fontSize="small" />
</IconButton></Tooltip> </IconButton></Tooltip>
</TableCell>} </TableCell>}
@ -114,11 +142,33 @@ export default function HostsPage() {
))} ))}
</TableBody> </TableBody>
</Table> </Table>
<TablePagination
component="div"
count={total}
page={page}
onPageChange={handleChangePage}
rowsPerPage={rowsPerPage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 25, 50, 100]}
/>
</TableContainer> </TableContainer>
)} )}
<Typography variant="caption" color="text.secondary" mt={1} display="block">
Showing {filtered.length} of {total} hosts <Dialog open={deleteTarget !== null} onClose={() => setDeleteTarget(null)}>
</Typography> <DialogTitle>Confirm Delete</DialogTitle>
<DialogContent>
Are you sure you want to delete host &ldquo;{deleteTarget?.display_name || deleteTarget?.fqdn}&rdquo;?
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteTarget(null)}>Cancel</Button>
<Button onClick={handleDelete} color="error" variant="contained">Delete</Button>
</DialogActions>
</Dialog>
<Snackbar open={snackbar.open} autoHideDuration={4000} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}>
<Alert severity={snackbar.severity} onClose={() => setSnackbar(s => ({ ...s, open: false }))}
sx={{ width: '100%' }}>{snackbar.message}</Alert>
</Snackbar>
</Container> </Container>
) )
} }

View File

@ -15,6 +15,26 @@ import { authApi, ssoConfigApi } from '../api/client'
import { useAuthStore } from '../store/authStore' import { useAuthStore } from '../store/authStore'
import type { User } from '../types' import type { User } from '../types'
// ── WebAuthn utility functions ──────────────────────────────────────────────
function arrayBufferToBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function base64urlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(base64 + padding)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
function getErrorMessage(err: unknown): string { function getErrorMessage(err: unknown): string {
if (err instanceof Error && err.message === 'Network Error') { if (err instanceof Error && err.message === 'Network Error') {
return 'Unable to connect to the server. Please check your network connection and try again.' return 'Unable to connect to the server. Please check your network connection and try again.'
@ -25,6 +45,7 @@ function getErrorMessage(err: unknown): string {
const msg = axiosErr.response?.data?.error?.message const msg = axiosErr.response?.data?.error?.message
if (status === 429) return 'Too many login attempts. Please wait a moment and try again.' if (status === 429) return 'Too many login attempts. Please wait a moment and try again.'
if (code === 'mfa_required') return 'MFA_REQUIRED' if (code === 'mfa_required') return 'MFA_REQUIRED'
if (code === 'mfa_required_webauthn') return 'MFA_REQUIRED_WEBAUTHN'
if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED' if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED'
if (code === 'account_locked') return 'ACCOUNT_LOCKED' if (code === 'account_locked') return 'ACCOUNT_LOCKED'
if (code === 'account_disabled') return 'This account has been disabled. Contact your administrator.' if (code === 'account_disabled') return 'This account has been disabled. Contact your administrator.'
@ -58,6 +79,8 @@ export default function LoginPage() {
const [totpCode, setTotpCode] = useState('') const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false) const [showPassword, setShowPassword] = useState(false)
const [needsMfa, setNeedsMfa] = useState(false) const [needsMfa, setNeedsMfa] = useState(false)
const [needsWebAuthn, setNeedsWebAuthn] = useState(false)
const [webAuthnLoading, setWebAuthnLoading] = useState(false)
const [forcePasswordReset, setForcePasswordReset] = useState(false) const [forcePasswordReset, setForcePasswordReset] = useState(false)
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -98,6 +121,9 @@ export default function LoginPage() {
if (message === 'MFA_REQUIRED') { if (message === 'MFA_REQUIRED') {
setNeedsMfa(true) setNeedsMfa(true)
setError('Please enter your MFA code.') setError('Please enter your MFA code.')
} else if (message === 'MFA_REQUIRED_WEBAUTHN') {
setNeedsWebAuthn(true)
setError('Please authenticate with your security key.')
} else if (message === 'PASSWORD_RESET_REQUIRED') { } else if (message === 'PASSWORD_RESET_REQUIRED') {
setForcePasswordReset(true) setForcePasswordReset(true)
setError('You must change your password before logging in.') setError('You must change your password before logging in.')
@ -111,6 +137,68 @@ export default function LoginPage() {
} }
} }
const handleWebAuthnLogin = async () => {
setWebAuthnLoading(true)
setError(null)
try {
const startRes = await authApi.webauthnAuthenticateStart()
const { challenge_key, assertion_options } = startRes.data
const publicKey = assertion_options.publicKey
const publicKeyCredentialRequestOptions: PublicKeyCredentialRequestOptions = {
...publicKey,
challenge: base64urlToArrayBuffer(publicKey.challenge),
allowCredentials: publicKey.allowCredentials?.map((c: { type: string; id: string }) => ({
...c,
id: base64urlToArrayBuffer(c.id),
})),
}
const assertion = await navigator.credentials.get({
publicKey: publicKeyCredentialRequestOptions,
}) as PublicKeyCredential | null
if (!assertion) {
setError('Security key authentication was cancelled.')
return
}
const response = assertion.response as AuthenticatorAssertionResponse
const serializedAssertion = {
id: assertion.id,
rawId: arrayBufferToBase64url(assertion.rawId),
type: assertion.type,
response: {
authenticatorData: arrayBufferToBase64url(response.authenticatorData),
clientDataJSON: arrayBufferToBase64url(response.clientDataJSON),
signature: arrayBufferToBase64url(response.signature),
userHandle: response.userHandle ? arrayBufferToBase64url(response.userHandle) : null,
},
}
const completeRes = await authApi.webauthnAuthenticateComplete(challenge_key, serializedAssertion)
if (completeRes.data.access_token && completeRes.data.refresh_token) {
const { access_token, refresh_token, user } = completeRes.data
setTokens(access_token, refresh_token)
setUser(user as User)
navigate('/dashboard', { replace: true })
} else {
setError('WebAuthn authentication succeeded. Please try logging in again.')
setNeedsWebAuthn(false)
}
} catch (err: unknown) {
const error = err as { name?: string; response?: { data?: { error?: { message?: string } } }; message?: string };
if (error.name === 'NotAllowedError') {
setError('Security key authentication was cancelled or timed out.');
} else {
const msg = error.response?.data?.error?.message || error.message || 'Authentication failed.';
setError(`Security key authentication failed: ${msg}`);
}
} finally {
setWebAuthnLoading(false)
}
}
const handleForceChangePassword = async (e: React.FormEvent) => { const handleForceChangePassword = async (e: React.FormEvent) => {
e.preventDefault() e.preventDefault()
if (!pwValid || pwMismatch) return if (!pwValid || pwMismatch) return
@ -196,11 +284,29 @@ export default function LoginPage() {
{needsMfa && ( {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" /> <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" />
)} )}
{needsWebAuthn && (
<Box sx={{ mt: 2, mb: 2 }}>
<Button
fullWidth
variant="contained"
size="large"
startIcon={<KeyIcon />}
onClick={handleWebAuthnLogin}
disabled={webAuthnLoading}
sx={{ mb: 1 }}
>
{webAuthnLoading ? <CircularProgress size={24} /> : 'Use Security Key'}
</Button>
<Typography variant="caption" color="text.secondary" display="block" textAlign="center">
Touch your security key or use your device biometrics to authenticate.
</Typography>
</Box>
)}
<Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Sign In'}</Button> <Button type="submit" fullWidth variant="contained" size="large" sx={{ mt: 3 }} disabled={loading}>{loading ? <CircularProgress size={24} /> : 'Sign In'}</Button>
{ssoEnabled && ( {ssoEnabled && (
<> <>
<Divider sx={{ my: 3 }}>or</Divider> <Divider sx={{ my: 3 }}>or</Divider>
<Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { window.location.href = ssoAuthUrl }} disabled={loading}>Sign in with {ssoDisplayName}</Button> <Button fullWidth variant="outlined" size="large" startIcon={ssoIcon} onClick={() => { const state = Array.from(crypto.getRandomValues(new Uint8Array(16))).map(b => b.toString(16).padStart(2, '0')).join(''); sessionStorage.setItem('sso_csrf_state', state); window.location.href = ssoAuthUrl }} disabled={loading}>Sign in with {ssoDisplayName}</Button>
</> </>
)} )}
</Box> </Box>

View File

@ -1,17 +1,45 @@
import React, { useEffect, useState } from 'react' import React, { useEffect, useState, useCallback } from 'react'
import { import {
Box, Button, Container, TextField, Typography, Box, Button, Container, TextField, Typography,
Alert, CircularProgress, Paper, Stepper, Step, StepLabel, Alert, CircularProgress, Paper, Stepper, Step, StepLabel,
IconButton, Tooltip, Snackbar, IconButton, Tooltip, Snackbar, Tabs, Tab, List, ListItem,
ListItemText, ListItemSecondaryAction, Dialog, DialogTitle,
DialogContent, DialogActions,
} from '@mui/material' } from '@mui/material'
import { ContentCopy as CopyIcon } from '@mui/icons-material' import {
ContentCopy as CopyIcon,
Delete as DeleteIcon,
VpnKey as KeyIcon,
Add as AddIcon,
} from '@mui/icons-material'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import { authApi } from '../api/client' import { authApi } from '../api/client'
import { useAuthStore } from '../store/authStore'
const STEPS = ['Get your QR code', 'Verify code', 'Done'] const STEPS = ['Get your QR code', 'Verify code', 'Done']
export default function MfaSetupPage() { // ── WebAuthn utility functions ──────────────────────────────────────────────
function arrayBufferToBase64url(buffer: ArrayBuffer): string {
return btoa(String.fromCharCode(...new Uint8Array(buffer)))
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
}
function base64urlToArrayBuffer(base64url: string): ArrayBuffer {
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
const padding = '='.repeat((4 - (base64.length % 4)) % 4)
const binary = atob(base64 + padding)
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i)
}
return bytes.buffer
}
// ── TOTP Setup Component ────────────────────────────────────────────────────
function TotpSetup() {
const [step, setStep] = useState(0) const [step, setStep] = useState(0)
const [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null) const [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null)
const [code, setCode] = useState('') const [code, setCode] = useState('')
@ -24,8 +52,6 @@ export default function MfaSetupPage() {
authApi.getMfaSetup() authApi.getMfaSetup()
.then((res) => { .then((res) => {
setSetup(res.data) setSetup(res.data)
console.warn('[MFA Setup] Success:', res.status, res.data)
// Generate QR code from otpauth URI
if (res.data.otp_uri) { if (res.data.otp_uri) {
QRCode.toDataURL(res.data.otp_uri, { QRCode.toDataURL(res.data.otp_uri, {
width: 256, width: 256,
@ -33,21 +59,14 @@ export default function MfaSetupPage() {
color: { dark: '#000000', light: '#ffffff' }, color: { dark: '#000000', light: '#ffffff' },
}) })
.then((url) => setQrDataUrl(url)) .then((url) => setQrDataUrl(url))
.catch((qrErr) => { .catch(() => setError('Failed to generate QR code.'))
console.error('[MFA Setup] QR generation failed:', qrErr)
setError('Failed to generate QR code.')
})
} else { } else {
console.error('[MFA Setup] No otp_uri in response:', res.data)
setError('MFA setup returned invalid data. No OTP URI found.') setError('MFA setup returned invalid data. No OTP URI found.')
} }
}) })
.catch((err) => { .catch((err) => {
const status = err?.response?.status const status = err?.response?.status
const data = err?.response?.data
const message = err?.message const message = err?.message
const token = useAuthStore.getState().accessToken
console.error('[MFA Setup] Failed:', { status, data, message, hasToken: !!token })
if (status === 401) { if (status === 401) {
setError('Authentication required. Please log in again.') setError('Authentication required. Please log in again.')
} else if (status === 403) { } else if (status === 403) {
@ -84,83 +103,65 @@ export default function MfaSetupPage() {
} }
return ( return (
<Container maxWidth="sm" sx={{ mt: 6 }}> <Box>
<Paper elevation={3} sx={{ p: 4 }}> <Stepper activeStep={step} sx={{ mb: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography> {STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
<Stepper activeStep={step} sx={{ mb: 4 }}> </Stepper>
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
</Stepper>
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>} {error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
{step === 0 && setup && ( {step === 0 && setup && (
<Box> <Box>
<Typography mb={2}> <Typography mb={2}>Scan this QR code in your authenticator app:</Typography>
Scan this QR code in your authenticator app: {qrDataUrl ? (
</Typography> <Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
{qrDataUrl ? ( <img src={qrDataUrl} alt="MFA QR Code" width={256} height={256} style={{ imageRendering: 'pixelated' }} />
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<img
src={qrDataUrl}
alt="MFA QR Code"
width={256}
height={256}
style={{ imageRendering: 'pixelated' }}
/>
</Box>
) : (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<CircularProgress />
</Box>
)}
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
If you can't scan the QR code, enter the secret manually:
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography
variant="body2"
sx={{
fontFamily: 'monospace',
wordBreak: 'break-all',
p: 1,
bgcolor: 'grey.100',
borderRadius: 1,
flexGrow: 1,
}}
>
{setup.secret_base32}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
<CopyIcon />
</IconButton>
</Tooltip>
</Box> </Box>
<Button variant="contained" onClick={() => setStep(1)}>Continue</Button> ) : (
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
<CircularProgress />
</Box>
)}
<Typography variant="caption" color="text.secondary" display="block" mb={1}>
If you can't scan the QR code, enter the secret manually:
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
<Typography
variant="body2"
sx={{ fontFamily: 'monospace', wordBreak: 'break-all', p: 1, bgcolor: 'grey.100', borderRadius: 1, flexGrow: 1 }}
>
{setup.secret_base32}
</Typography>
<Tooltip title={copied ? 'Copied!' : 'Copy Secret'}>
<IconButton onClick={handleCopySecret} color={copied ? 'success' : 'default'}>
<CopyIcon />
</IconButton>
</Tooltip>
</Box> </Box>
)} <Button variant="contained" onClick={() => setStep(1)}>Continue</Button>
</Box>
)}
{step === 1 && ( {step === 1 && (
<Box component="form" onSubmit={handleVerify}> <Box component="form" onSubmit={handleVerify}>
<Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography> <Typography mb={2}>Enter the 6-digit code from your authenticator app to confirm setup:</Typography>
<TextField <TextField
fullWidth label="Verification Code" inputMode="numeric" fullWidth label="Verification Code" inputMode="numeric"
inputProps={{ maxLength: 6, pattern: '[0-9]*' }} inputProps={{ maxLength: 6, pattern: '[0-9]*' }}
value={code} onChange={(e) => setCode(e.target.value)} value={code} onChange={(e) => setCode(e.target.value)}
disabled={loading} required autoFocus disabled={loading} required autoFocus
/> />
<Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}> <Button type="submit" variant="contained" sx={{ mt: 2 }} disabled={loading}>
{loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'} {loading ? <CircularProgress size={24} /> : 'Verify & Enable MFA'}
</Button> </Button>
</Box> </Box>
)} )}
{step === 2 && ( {step === 2 && (
<Alert severity="success"> <Alert severity="success">
MFA has been enabled for your account. You will need your authenticator app at each login. MFA has been enabled for your account. You will need your authenticator app at each login.
</Alert> </Alert>
)} )}
</Paper>
<Snackbar <Snackbar
open={copied} open={copied}
@ -170,6 +171,231 @@ export default function MfaSetupPage() {
> >
<Alert severity="success" variant="filled">Secret copied to clipboard</Alert> <Alert severity="success" variant="filled">Secret copied to clipboard</Alert>
</Snackbar> </Snackbar>
</Box>
)
}
// ── WebAuthn Setup Component ────────────────────────────────────────────────
interface WebAuthnCredential {
id: string
name: string
created_at: string
}
function WebAuthnSetup() {
const [credentials, setCredentials] = useState<WebAuthnCredential[]>([])
const [loading, setLoading] = useState(false)
const [registering, setRegistering] = useState(false)
const [keyName, setKeyName] = useState('')
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null)
const loadCredentials = useCallback(() => {
authApi.webauthnListCredentials()
.then((res) => {
setCredentials(res.data.credentials || [])
})
.catch((err) => {
console.error('[WebAuthn] Failed to load credentials:', err)
setError('Failed to load security keys.')
})
}, [])
useEffect(() => {
loadCredentials()
}, [loadCredentials])
const handleRegister = async () => {
setRegistering(true)
setError(null)
setSuccess(null)
try {
// Step 1: Start registration ceremony
const startRes = await authApi.webauthnRegisterStart(keyName || undefined)
const { challenge_key, creation_options } = startRes.data
// Step 2: Convert base64url strings to ArrayBuffers for navigator.credentials.create
const publicKey = creation_options.publicKey
const publicKeyCredentialCreationOptions: PublicKeyCredentialCreationOptions = {
...publicKey,
challenge: base64urlToArrayBuffer(publicKey.challenge),
user: {
...publicKey.user,
id: base64urlToArrayBuffer(publicKey.user.id),
},
excludeCredentials: publicKey.excludeCredentials?.map((c: { type: string; id: string }) => ({
...c,
id: base64urlToArrayBuffer(c.id),
})),
}
// Step 3: Create credential via browser WebAuthn API
const credential = await navigator.credentials.create({
publicKey: publicKeyCredentialCreationOptions,
}) as PublicKeyCredential | null
if (!credential) {
setError('Security key registration was cancelled.')
return
}
// Step 4: Serialize credential for server
const response = credential.response as AuthenticatorAttestationResponse
const serializedCredential = {
id: credential.id,
rawId: arrayBufferToBase64url(credential.rawId),
type: credential.type,
response: {
attestationObject: arrayBufferToBase64url(response.attestationObject),
clientDataJSON: arrayBufferToBase64url(response.clientDataJSON),
},
}
// Step 5: Complete registration
await authApi.webauthnRegisterComplete(challenge_key, serializedCredential, keyName || undefined)
setSuccess('Security key registered successfully!')
setKeyName('')
loadCredentials()
} catch (err: unknown) {
const errorObj = err as { name?: string; response?: { data?: { error?: { message?: string } } }; message?: string }
if (errorObj.name === 'NotAllowedError') {
setError('Security key registration was cancelled or timed out.')
} else {
const msg = errorObj.response?.data?.error?.message || errorObj.message || 'Registration failed.'
setError(`Failed to register security key: ${msg}`)
}
} finally {
setRegistering(false)
}
}
const handleDelete = async (id: string) => {
setDeleteConfirm(null)
setLoading(true)
setError(null)
try {
await authApi.webauthnDeleteCredential(id)
setSuccess('Security key removed successfully.')
loadCredentials()
} catch (err: unknown) {
const errorObj = err as { response?: { data?: { error?: { message?: string } } }; message?: string }
const msg = errorObj.response?.data?.error?.message || errorObj.message || 'Failed to delete key.'
setError(`Failed to remove security key: ${msg}`)
} finally {
setLoading(false)
}
}
return (
<Box>
{error && <Alert severity="error" sx={{ mb: 2 }} onClose={() => setError(null)}>{error}</Alert>}
{success && <Alert severity="success" sx={{ mb: 2 }} onClose={() => setSuccess(null)}>{success}</Alert>}
<Typography variant="h6" fontWeight={600} mb={2}>
Register a Security Key
</Typography>
<Typography variant="body2" color="text.secondary" mb={2}>
Add a FIDO2/WebAuthn security key (e.g., YubiKey) for passwordless authentication.
You can register multiple keys as backups.
</Typography>
<Box sx={{ display: 'flex', gap: 1, mb: 3, alignItems: 'flex-start' }}>
<TextField
size="small"
label="Key Name (optional)"
placeholder="e.g., My YubiKey"
value={keyName}
onChange={(e) => setKeyName(e.target.value)}
disabled={registering}
sx={{ flexGrow: 1 }}
/>
<Button
variant="contained"
startIcon={<AddIcon />}
onClick={handleRegister}
disabled={registering}
>
{registering ? <CircularProgress size={24} /> : 'Register Security Key'}
</Button>
</Box>
<Typography variant="h6" fontWeight={600} mb={1}>
Registered Security Keys
</Typography>
{credentials.length === 0 ? (
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
No security keys registered yet.
</Typography>
) : (
<List>
{credentials.map((cred) => (
<ListItem key={cred.id} sx={{ bgcolor: 'grey.50', borderRadius: 1, mb: 1 }}>
<KeyIcon sx={{ mr: 2, color: 'action.active' }} />
<ListItemText
primary={cred.name || 'Unnamed Key'}
secondary={`Added ${new Date(cred.created_at).toLocaleDateString()}`}
/>
<ListItemSecondaryAction>
<Tooltip title="Remove Key">
<IconButton
edge="end"
color="error"
onClick={() => setDeleteConfirm(cred.id)}
disabled={loading}
>
<DeleteIcon />
</IconButton>
</Tooltip>
</ListItemSecondaryAction>
</ListItem>
))}
</List>
)}
{/* Delete confirmation dialog */}
<Dialog open={!!deleteConfirm} onClose={() => setDeleteConfirm(null)}>
<DialogTitle>Remove Security Key?</DialogTitle>
<DialogContent>
<Typography>
Are you sure you want to remove this security key? You will no longer be able to use it to sign in.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setDeleteConfirm(null)}>Cancel</Button>
<Button
color="error"
variant="contained"
onClick={() => deleteConfirm && handleDelete(deleteConfirm)}
>
Remove
</Button>
</DialogActions>
</Dialog>
</Box>
)
}
// ── Main MFA Setup Page ─────────────────────────────────────────────────────
export default function MfaSetupPage() {
const [activeTab, setActiveTab] = useState(0)
return (
<Container maxWidth="sm" sx={{ mt: 6 }}>
<Paper elevation={3} sx={{ p: 4 }}>
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
<Tabs value={activeTab} onChange={(_, v) => setActiveTab(v)} sx={{ mb: 3 }}>
<Tab label="Authenticator App" icon={<CopyIcon />} iconPosition="start" />
<Tab label="Security Key" icon={<KeyIcon />} iconPosition="start" />
</Tabs>
{activeTab === 0 && <TotpSetup />}
{activeTab === 1 && <WebAuthnSetup />}
</Paper>
</Container> </Container>
) )
} }