Phase 4: Exhaustive analysis fixes, security hardening, and code quality improvements
This commit is contained in:
@ -121,6 +121,25 @@ export const authApi = {
|
||||
|
||||
verifyMfa: (secretBase32: string, code: string) =>
|
||||
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 ──────────────────────────────────────────────────────
|
||||
|
||||
@ -1,8 +1,9 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Box, Button, Chip, CircularProgress, Container, IconButton,
|
||||
Paper, Table, TableBody, TableCell, TableContainer, TableHead,
|
||||
TableRow, TextField, Toolbar, Tooltip, Typography,
|
||||
Box, Button, Chip, CircularProgress, Container, Dialog, DialogTitle,
|
||||
DialogContent, DialogActions, IconButton, Paper, Snackbar, Alert,
|
||||
Table, TableBody, TableCell, TableContainer, TableHead, TableRow,
|
||||
TablePagination, TextField, Toolbar, Tooltip, Typography,
|
||||
} 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 { useNavigate } from 'react-router-dom'
|
||||
@ -19,19 +20,24 @@ export default function HostsPage() {
|
||||
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(0)
|
||||
const [rowsPerPage, setRowsPerPage] = useState(25)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
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 () => {
|
||||
setLoading(true)
|
||||
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)
|
||||
setTotal(res.data.total)
|
||||
} catch { /* handled by interceptor */ }
|
||||
finally { setLoading(false) }
|
||||
}, [])
|
||||
}, [page, rowsPerPage])
|
||||
|
||||
const handleRefresh = async (e: React.MouseEvent, hostId: string) => {
|
||||
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 =>
|
||||
h.fqdn.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 (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
@ -60,7 +88,7 @@ export default function HostsPage() {
|
||||
<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>}
|
||||
</Toolbar>
|
||||
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
||||
{loading ? <Box display="flex" justifyContent="center" mt="4"><CircularProgress /></Box> : (
|
||||
<TableContainer component={Paper}>
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
@ -106,7 +134,7 @@ export default function HostsPage() {
|
||||
: <RefreshIcon fontSize="small" />}
|
||||
</IconButton>
|
||||
</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" />
|
||||
</IconButton></Tooltip>
|
||||
</TableCell>}
|
||||
@ -114,11 +142,33 @@ export default function HostsPage() {
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
<TablePagination
|
||||
component="div"
|
||||
count={total}
|
||||
page={page}
|
||||
onPageChange={handleChangePage}
|
||||
rowsPerPage={rowsPerPage}
|
||||
onRowsPerPageChange={handleChangeRowsPerPage}
|
||||
rowsPerPageOptions={[10, 25, 50, 100]}
|
||||
/>
|
||||
</TableContainer>
|
||||
)}
|
||||
<Typography variant="caption" color="text.secondary" mt={1} display="block">
|
||||
Showing {filtered.length} of {total} hosts
|
||||
</Typography>
|
||||
|
||||
<Dialog open={deleteTarget !== null} onClose={() => setDeleteTarget(null)}>
|
||||
<DialogTitle>Confirm Delete</DialogTitle>
|
||||
<DialogContent>
|
||||
Are you sure you want to delete host “{deleteTarget?.display_name || deleteTarget?.fqdn}”?
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -15,6 +15,26 @@ import { authApi, ssoConfigApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
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 {
|
||||
if (err instanceof Error && err.message === 'Network Error') {
|
||||
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
|
||||
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_webauthn') return 'MFA_REQUIRED_WEBAUTHN'
|
||||
if (code === 'password_reset_required') return 'PASSWORD_RESET_REQUIRED'
|
||||
if (code === 'account_locked') return 'ACCOUNT_LOCKED'
|
||||
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 [showPassword, setShowPassword] = useState(false)
|
||||
const [needsMfa, setNeedsMfa] = useState(false)
|
||||
const [needsWebAuthn, setNeedsWebAuthn] = useState(false)
|
||||
const [webAuthnLoading, setWebAuthnLoading] = useState(false)
|
||||
const [forcePasswordReset, setForcePasswordReset] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
@ -98,6 +121,9 @@ export default function LoginPage() {
|
||||
if (message === 'MFA_REQUIRED') {
|
||||
setNeedsMfa(true)
|
||||
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') {
|
||||
setForcePasswordReset(true)
|
||||
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) => {
|
||||
e.preventDefault()
|
||||
if (!pwValid || pwMismatch) return
|
||||
@ -196,11 +284,29 @@ export default function LoginPage() {
|
||||
{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" />
|
||||
)}
|
||||
{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>
|
||||
{ssoEnabled && (
|
||||
<>
|
||||
<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>
|
||||
|
||||
@ -1,17 +1,45 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import React, { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Box, Button, Container, TextField, Typography,
|
||||
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'
|
||||
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 { authApi } from '../api/client'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
|
||||
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 [setup, setSetup] = useState<{ secret_base32: string; otp_uri: string } | null>(null)
|
||||
const [code, setCode] = useState('')
|
||||
@ -24,8 +52,6 @@ export default function MfaSetupPage() {
|
||||
authApi.getMfaSetup()
|
||||
.then((res) => {
|
||||
setSetup(res.data)
|
||||
console.warn('[MFA Setup] Success:', res.status, res.data)
|
||||
// Generate QR code from otpauth URI
|
||||
if (res.data.otp_uri) {
|
||||
QRCode.toDataURL(res.data.otp_uri, {
|
||||
width: 256,
|
||||
@ -33,21 +59,14 @@ export default function MfaSetupPage() {
|
||||
color: { dark: '#000000', light: '#ffffff' },
|
||||
})
|
||||
.then((url) => setQrDataUrl(url))
|
||||
.catch((qrErr) => {
|
||||
console.error('[MFA Setup] QR generation failed:', qrErr)
|
||||
setError('Failed to generate QR code.')
|
||||
})
|
||||
.catch(() => setError('Failed to generate QR code.'))
|
||||
} else {
|
||||
console.error('[MFA Setup] No otp_uri in response:', res.data)
|
||||
setError('MFA setup returned invalid data. No OTP URI found.')
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
const status = err?.response?.status
|
||||
const data = err?.response?.data
|
||||
const message = err?.message
|
||||
const token = useAuthStore.getState().accessToken
|
||||
console.error('[MFA Setup] Failed:', { status, data, message, hasToken: !!token })
|
||||
if (status === 401) {
|
||||
setError('Authentication required. Please log in again.')
|
||||
} else if (status === 403) {
|
||||
@ -84,83 +103,65 @@ export default function MfaSetupPage() {
|
||||
}
|
||||
|
||||
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>
|
||||
<Box>
|
||||
<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>}
|
||||
{error && <Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>}
|
||||
|
||||
{step === 0 && setup && (
|
||||
<Box>
|
||||
<Typography mb={2}>
|
||||
Scan this QR code in your authenticator app:
|
||||
</Typography>
|
||||
{qrDataUrl ? (
|
||||
<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>
|
||||
{step === 0 && setup && (
|
||||
<Box>
|
||||
<Typography mb={2}>Scan this QR code in your authenticator app:</Typography>
|
||||
{qrDataUrl ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||
<img src={qrDataUrl} alt="MFA QR Code" width={256} height={256} style={{ imageRendering: 'pixelated' }} />
|
||||
</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>
|
||||
)}
|
||||
<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 === 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>
|
||||
{step === 2 && (
|
||||
<Alert severity="success">
|
||||
MFA has been enabled for your account. You will need your authenticator app at each login.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Snackbar
|
||||
open={copied}
|
||||
@ -170,6 +171,231 @@ export default function MfaSetupPage() {
|
||||
>
|
||||
<Alert severity="success" variant="filled">Secret copied to clipboard</Alert>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user