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) =>
|
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 ──────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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 “{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>
|
</Container>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,9 +103,7 @@ export default function MfaSetupPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container maxWidth="sm" sx={{ mt: 6 }}>
|
<Box>
|
||||||
<Paper elevation={3} sx={{ p: 4 }}>
|
|
||||||
<Typography variant="h5" fontWeight={700} mb={3}>Set Up MFA</Typography>
|
|
||||||
<Stepper activeStep={step} sx={{ mb: 4 }}>
|
<Stepper activeStep={step} sx={{ mb: 4 }}>
|
||||||
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
|
{STEPS.map((label) => <Step key={label}><StepLabel>{label}</StepLabel></Step>)}
|
||||||
</Stepper>
|
</Stepper>
|
||||||
@ -95,18 +112,10 @@ export default function MfaSetupPage() {
|
|||||||
|
|
||||||
{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:
|
|
||||||
</Typography>
|
|
||||||
{qrDataUrl ? (
|
{qrDataUrl ? (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||||
<img
|
<img src={qrDataUrl} alt="MFA QR Code" width={256} height={256} style={{ imageRendering: 'pixelated' }} />
|
||||||
src={qrDataUrl}
|
|
||||||
alt="MFA QR Code"
|
|
||||||
width={256}
|
|
||||||
height={256}
|
|
||||||
style={{ imageRendering: 'pixelated' }}
|
|
||||||
/>
|
|
||||||
</Box>
|
</Box>
|
||||||
) : (
|
) : (
|
||||||
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 2 }}>
|
||||||
@ -119,14 +128,7 @@ export default function MfaSetupPage() {
|
|||||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 3 }}>
|
||||||
<Typography
|
<Typography
|
||||||
variant="body2"
|
variant="body2"
|
||||||
sx={{
|
sx={{ fontFamily: 'monospace', wordBreak: 'break-all', p: 1, bgcolor: 'grey.100', borderRadius: 1, flexGrow: 1 }}
|
||||||
fontFamily: 'monospace',
|
|
||||||
wordBreak: 'break-all',
|
|
||||||
p: 1,
|
|
||||||
bgcolor: 'grey.100',
|
|
||||||
borderRadius: 1,
|
|
||||||
flexGrow: 1,
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{setup.secret_base32}
|
{setup.secret_base32}
|
||||||
</Typography>
|
</Typography>
|
||||||
@ -160,7 +162,6 @@ export default function MfaSetupPage() {
|
|||||||
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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user