From 7b067b2813ab936d943d2df903daf3814aa8b291 Mon Sep 17 00:00:00 2001 From: Echo Date: Fri, 15 May 2026 22:10:05 +0000 Subject: [PATCH] Phase 4: Exhaustive analysis fixes, security hardening, and code quality improvements --- frontend/src/api/client.ts | 19 ++ frontend/src/pages/HostsPage.tsx | 72 ++++- frontend/src/pages/LoginPage.tsx | 108 +++++++- frontend/src/pages/MfaSetupPage.tsx | 398 ++++++++++++++++++++++------ 4 files changed, 499 insertions(+), 98 deletions(-) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ab3a133..30e7c54 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -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 ────────────────────────────────────────────────────── diff --git a/frontend/src/pages/HostsPage.tsx b/frontend/src/pages/HostsPage.tsx index e968448..11f2a1c 100644 --- a/frontend/src/pages/HostsPage.tsx +++ b/frontend/src/pages/HostsPage.tsx @@ -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([]) 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(null) + const [deleteTarget, setDeleteTarget] = useState(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 | null, newPage: number) => { + setPage(newPage) + } + + const handleChangeRowsPerPage = (event: React.ChangeEvent) => { + setRowsPerPage(parseInt(event.target.value, 10)) + setPage(0) + } + return ( @@ -60,7 +88,7 @@ export default function HostsPage() { {canWrite && } - {loading ? : ( + {loading ? : ( @@ -106,7 +134,7 @@ export default function HostsPage() { : } - + { e.stopPropagation(); setDeleteTarget(h) }}> } @@ -114,11 +142,33 @@ export default function HostsPage() { ))}
+
)} - - Showing {filtered.length} of {total} hosts - + + setDeleteTarget(null)}> + Confirm Delete + + Are you sure you want to delete host “{deleteTarget?.display_name || deleteTarget?.fqdn}”? + + + + + + + setSnackbar(s => ({ ...s, open: false }))} + anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}> + setSnackbar(s => ({ ...s, open: false }))} + sx={{ width: '100%' }}>{snackbar.message} +
) } diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index 311c1f2..aa7f498 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -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(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 && ( setTotpCode(e.target.value)} disabled={loading} required autoFocus helperText="Enter the 6-digit code from your authenticator app" /> )} + {needsWebAuthn && ( + + + + Touch your security key or use your device biometrics to authenticate. + + + )} {ssoEnabled && ( <> or - + )} diff --git a/frontend/src/pages/MfaSetupPage.tsx b/frontend/src/pages/MfaSetupPage.tsx index 4269170..571f969 100644 --- a/frontend/src/pages/MfaSetupPage.tsx +++ b/frontend/src/pages/MfaSetupPage.tsx @@ -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 ( - - - Set Up MFA - - {STEPS.map((label) => {label})} - + + + {STEPS.map((label) => {label})} + - {error && {error}} + {error && {error}} - {step === 0 && setup && ( - - - Scan this QR code in your authenticator app: - - {qrDataUrl ? ( - - MFA QR Code - - ) : ( - - - - )} - - If you can't scan the QR code, enter the secret manually: - - - - {setup.secret_base32} - - - - - - + {step === 0 && setup && ( + + Scan this QR code in your authenticator app: + {qrDataUrl ? ( + + MFA QR Code - + ) : ( + + + + )} + + If you can't scan the QR code, enter the secret manually: + + + + {setup.secret_base32} + + + + + + - )} + + + )} - {step === 1 && ( - - Enter the 6-digit code from your authenticator app to confirm setup: - setCode(e.target.value)} - disabled={loading} required autoFocus - /> - - - )} + {step === 1 && ( + + Enter the 6-digit code from your authenticator app to confirm setup: + setCode(e.target.value)} + disabled={loading} required autoFocus + /> + + + )} - {step === 2 && ( - - MFA has been enabled for your account. You will need your authenticator app at each login. - - )} - + {step === 2 && ( + + MFA has been enabled for your account. You will need your authenticator app at each login. + + )} Secret copied to clipboard + + ) +} + +// ── WebAuthn Setup Component ──────────────────────────────────────────────── + +interface WebAuthnCredential { + id: string + name: string + created_at: string +} + +function WebAuthnSetup() { + const [credentials, setCredentials] = useState([]) + const [loading, setLoading] = useState(false) + const [registering, setRegistering] = useState(false) + const [keyName, setKeyName] = useState('') + const [error, setError] = useState(null) + const [success, setSuccess] = useState(null) + const [deleteConfirm, setDeleteConfirm] = useState(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 ( + + {error && setError(null)}>{error}} + {success && setSuccess(null)}>{success}} + + + Register a Security Key + + + Add a FIDO2/WebAuthn security key (e.g., YubiKey) for passwordless authentication. + You can register multiple keys as backups. + + + + setKeyName(e.target.value)} + disabled={registering} + sx={{ flexGrow: 1 }} + /> + + + + + Registered Security Keys + + + {credentials.length === 0 ? ( + + No security keys registered yet. + + ) : ( + + {credentials.map((cred) => ( + + + + + + setDeleteConfirm(cred.id)} + disabled={loading} + > + + + + + + ))} + + )} + + {/* Delete confirmation dialog */} + setDeleteConfirm(null)}> + Remove Security Key? + + + Are you sure you want to remove this security key? You will no longer be able to use it to sign in. + + + + + + + + + ) +} + +// ── Main MFA Setup Page ───────────────────────────────────────────────────── + +export default function MfaSetupPage() { + const [activeTab, setActiveTab] = useState(0) + + return ( + + + Set Up MFA + + setActiveTab(v)} sx={{ mb: 3 }}> + } iconPosition="start" /> + } iconPosition="start" /> + + + {activeTab === 0 && } + {activeTab === 1 && } + ) }