diff --git a/messages/en-US.json b/messages/en-US.json index ccc6afd4..4814388f 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1143,8 +1143,7 @@ "securityKeyNameRequired": "Name is required", "securityKeyRemove": "Remove", "securityKeyLastUsed": "Last used: {date}", - "securityKeyNameLabel": "Name", - "securityKeyNamePlaceholder": "Enter a name for this security key", + "securityKeyNameLabel": "Security Key Name", "securityKeyRegisterSuccess": "Security key registered successfully", "securityKeyRegisterError": "Failed to register security key", "securityKeyRemoveSuccess": "Security key removed successfully", @@ -1152,7 +1151,7 @@ "securityKeyLoadError": "Failed to load security keys", "securityKeyLogin": "Sign in with security key", "securityKeyAuthError": "Failed to authenticate with security key", - "securityKeyRecommendation": "Tip: Register a backup security key on another device to ensure you always have access to your account.", + "securityKeyRecommendation": "Register a backup security key on another device to ensure you always have access to your account.", "registering": "Registering...", "securityKeyPrompt": "Please verify your identity using your security key. Make sure your security key is connected and ready.", "securityKeyBrowserNotSupported": "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari.", @@ -1163,5 +1162,16 @@ "twoFactorRequired": "Two-factor authentication is required to register a security key.", "twoFactor": "Two-Factor Authentication", "adminEnabled2FaOnYourAccount": "Your administrator has enabled two-factor authentication for {email}. Please complete the setup process to continue.", - "continueToApplication": "Continue to Application" + "continueToApplication": "Continue to Application", + "securityKeyAdd": "Add Security Key", + "securityKeyRegisterTitle": "Register New Security Key", + "securityKeyRegisterDescription": "Connect your security key and enter a name to identify it", + "securityKeyTwoFactorRequired": "Two-Factor Authentication Required", + "securityKeyTwoFactorDescription": "Please enter your two-factor authentication code to register the security key", + "securityKeyTwoFactorRemoveDescription": "Please enter your two-factor authentication code to remove the security key", + "securityKeyTwoFactorCode": "Two-Factor Code", + "securityKeyRemoveTitle": "Remove Security Key", + "securityKeyRemoveDescription": "Enter your password to remove the security key \"{name}\"", + "securityKeyNoKeysRegistered": "No security keys registered", + "securityKeyNoKeysDescription": "Add a security key to enhance your account security" } diff --git a/package-lock.json b/package-lock.json index e0f2d0dd..cb44266b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,9 +33,9 @@ "@radix-ui/react-toast": "1.2.14", "@react-email/components": "0.3.1", "@react-email/render": "^1.1.2", + "@react-email/tailwind": "1.2.1", "@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/server": "^9.0.3", - "@react-email/tailwind": "1.2.1", "@tailwindcss/forms": "^0.5.10", "@tanstack/react-table": "8.21.3", "arctic": "^3.7.0", diff --git a/server/routers/auth/securityKey.ts b/server/routers/auth/securityKey.ts index 4e642ece..dad3c692 100644 --- a/server/routers/auth/securityKey.ts +++ b/server/routers/auth/securityKey.ts @@ -107,7 +107,8 @@ async function clearChallenge(sessionId: string) { export const registerSecurityKeyBody = z.object({ name: z.string().min(1), - password: z.string().min(1) + password: z.string().min(1), + code: z.string().optional() }).strict(); export const verifyRegistrationBody = z.object({ @@ -143,7 +144,7 @@ export async function startRegistration( ); } - const { name, password } = parsedBody.data; + const { name, password, code } = parsedBody.data; const user = req.user as User; // Only allow internal users to use security keys @@ -163,6 +164,39 @@ export async function startRegistration( return next(unauthorized()); } + // If user has 2FA enabled, require and verify the code + if (user.twoFactorEnabled) { + if (!code) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + + const validOTP = await verifyTotpCode( + code, + user.twoFactorSecret!, + user.userId + ); + + if (!validOTP) { + if (config.getRawConfig().app.log_failed_attempts) { + logger.info( + `Two-factor code incorrect. Email: ${user.email}. IP: ${req.ip}.` + ); + } + return next( + createHttpError( + HttpCode.UNAUTHORIZED, + "The two-factor code you entered is incorrect" + ) + ); + } + } + // Get existing security keys for user const existingSecurityKeys = await db .select() diff --git a/src/components/SecurityKeyForm.tsx b/src/components/SecurityKeyForm.tsx index 5b5ab3bb..de029b10 100644 --- a/src/components/SecurityKeyForm.tsx +++ b/src/components/SecurityKeyForm.tsx @@ -15,23 +15,25 @@ import { FormField, FormItem, FormLabel, - FormMessage, + FormMessage } from "@app/components/ui/form"; import { Input } from "@app/components/ui/input"; import { Alert, AlertDescription } from "@app/components/ui/alert"; import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@app/components/ui/dialog"; + Credenza, + CredenzaBody, + CredenzaClose, + CredenzaContent, + CredenzaDescription, + CredenzaFooter, + CredenzaHeader, + CredenzaTitle +} from "@app/components/Credenza"; import { startRegistration } from "@simplewebauthn/browser"; import { useEnvContext } from "@app/hooks/useEnvContext"; import { Card, CardContent } from "@app/components/ui/card"; import { Badge } from "@app/components/ui/badge"; -import { Loader2, KeyRound, Trash2, Plus, Shield } from "lucide-react"; +import { Loader2, KeyRound, Trash2, Plus, Shield, Info } from "lucide-react"; import { cn } from "@app/lib/cn"; type SecurityKeyFormProps = { @@ -53,6 +55,7 @@ type DeleteSecurityKeyData = { type RegisterFormValues = { name: string; password: string; + code?: string; }; type DeleteFormValues = { @@ -70,30 +73,47 @@ type FieldProps = { }; }; -export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) { +export default function SecurityKeyForm({ + open, + setOpen +}: SecurityKeyFormProps) { const t = useTranslations(); const { env } = useEnvContext(); const api = createApiClient({ env }); const [securityKeys, setSecurityKeys] = useState([]); const [isRegistering, setIsRegistering] = useState(false); - const [showRegisterDialog, setShowRegisterDialog] = useState(false); - const [selectedSecurityKey, setSelectedSecurityKey] = useState(null); - const [show2FADialog, setShow2FADialog] = useState(false); + const [dialogState, setDialogState] = useState< + "list" | "register" | "register2fa" | "delete" | "delete2fa" + >("list"); + const [selectedSecurityKey, setSelectedSecurityKey] = + useState(null); const [deleteInProgress, setDeleteInProgress] = useState(false); - const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState(null); - const [pendingDeletePassword, setPendingDeletePassword] = useState(null); + const [pendingDeleteCredentialId, setPendingDeleteCredentialId] = useState< + string | null + >(null); + const [pendingDeletePassword, setPendingDeletePassword] = useState< + string | null + >(null); + const [pendingRegisterData, setPendingRegisterData] = useState<{ + name: string; + password: string; + } | null>(null); + const [register2FAForm, setRegister2FAForm] = useState<{ code: string }>({ + code: "" + }); useEffect(() => { loadSecurityKeys(); }, []); const registerSchema = z.object({ - name: z.string().min(1, { message: t('securityKeyNameRequired') }), - password: z.string().min(1, { message: t('passwordRequired') }), + name: z.string().min(1, { message: t("securityKeyNameRequired") }), + password: z.string().min(1, { message: t("passwordRequired") }), + code: z.string().optional() }); const deleteSchema = z.object({ - password: z.string().min(1, { message: t('passwordRequired') }), + password: z.string().min(1, { message: t("passwordRequired") }), code: z.string().optional() }); @@ -102,7 +122,8 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) defaultValues: { name: "", password: "", - }, + code: "" + } }); const deleteForm = useForm({ @@ -110,7 +131,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) defaultValues: { password: "", code: "" - }, + } }); const loadSecurityKeys = async () => { @@ -120,7 +141,7 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) } catch (error) { toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyLoadError')), + description: formatAxiosError(error, t("securityKeyLoadError")) }); } }; @@ -131,88 +152,101 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) if (!window.PublicKeyCredential) { toast({ variant: "destructive", - description: t('securityKeyBrowserNotSupported', { - defaultValue: "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." + description: t("securityKeyBrowserNotSupported", { + defaultValue: + "Your browser doesn't support security keys. Please use a modern browser like Chrome, Firefox, or Safari." }) }); return; } setIsRegistering(true); - const startRes = await api.post("/auth/security-key/register/start", { - name: values.name, - password: values.password, - }); + const startRes = await api.post( + "/auth/security-key/register/start", + { + name: values.name, + password: values.password, + code: values.code + } + ); - if (startRes.status === 202) { - toast({ - variant: "destructive", - description: t('twoFactorRequired', { - defaultValue: "Two-factor authentication is required to register a security key." - }) + // If 2FA is required + if (startRes.status === 202 && startRes.data.data?.codeRequested) { + setPendingRegisterData({ + name: values.name, + password: values.password }); + setDialogState("register2fa"); + setIsRegistering(false); return; } const options = startRes.data.data; - + try { const credential = await startRegistration(options); await api.post("/auth/security-key/register/verify", { - credential, + credential }); toast({ - description: t('securityKeyRegisterSuccess', { + description: t("securityKeyRegisterSuccess", { defaultValue: "Security key registered successfully" }) }); registerForm.reset(); - setShowRegisterDialog(false); + setDialogState("list"); await loadSecurityKeys(); } catch (error: any) { - if (error.name === 'NotAllowedError') { - if (error.message.includes('denied permission')) { + if (error.name === "NotAllowedError") { + if (error.message.includes("denied permission")) { toast({ variant: "destructive", - description: t('securityKeyPermissionDenied', { - defaultValue: "Please allow access to your security key to continue registration." + description: t("securityKeyPermissionDenied", { + defaultValue: + "Please allow access to your security key to continue registration." }) }); } else { toast({ variant: "destructive", - description: t('securityKeyRemovedTooQuickly', { - defaultValue: "Please keep your security key connected until the registration process completes." + description: t("securityKeyRemovedTooQuickly", { + defaultValue: + "Please keep your security key connected until the registration process completes." }) }); } - } else if (error.name === 'NotSupportedError') { + } else if (error.name === "NotSupportedError") { toast({ variant: "destructive", - description: t('securityKeyNotSupported', { - defaultValue: "Your security key may not be compatible. Please try a different security key." + description: t("securityKeyNotSupported", { + defaultValue: + "Your security key may not be compatible. Please try a different security key." }) }); } else { toast({ variant: "destructive", - description: t('securityKeyUnknownError', { - defaultValue: "There was a problem registering your security key. Please try again." + description: t("securityKeyUnknownError", { + defaultValue: + "There was a problem registering your security key. Please try again." }) }); } throw error; // Re-throw to be caught by outer catch } } catch (error) { - console.error('Security key registration error:', error); + console.error("Security key registration error:", error); toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRegisterError', { - defaultValue: "Failed to register security key" - })) + description: formatAxiosError( + error, + t("securityKeyRegisterError", { + defaultValue: "Failed to register security key" + }) + ) }); } finally { setIsRegistering(false); @@ -224,33 +258,42 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) try { setDeleteInProgress(true); - const encodedCredentialId = encodeURIComponent(selectedSecurityKey.credentialId); - const response = await api.delete(`/auth/security-key/${encodedCredentialId}`, { - data: { - password: values.password, - code: values.code + const encodedCredentialId = encodeURIComponent( + selectedSecurityKey.credentialId + ); + const response = await api.delete( + `/auth/security-key/${encodedCredentialId}`, + { + data: { + password: values.password, + code: values.code + } } - }); + ); // If 2FA is required if (response.status === 202 && response.data.data.codeRequested) { setPendingDeleteCredentialId(encodedCredentialId); setPendingDeletePassword(values.password); - setShow2FADialog(true); + setDialogState("delete2fa"); return; } toast({ - description: t('securityKeyRemoveSuccess') + description: t("securityKeyRemoveSuccess") }); deleteForm.reset(); setSelectedSecurityKey(null); + setDialogState("list"); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRemoveError')), + description: formatAxiosError( + error, + t("securityKeyRemoveError") + ) }); } finally { setDeleteInProgress(false); @@ -262,33 +305,128 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) try { setDeleteInProgress(true); - await api.delete(`/auth/security-key/${pendingDeleteCredentialId}`, { - data: { - password: pendingDeletePassword, - code: values.code + await api.delete( + `/auth/security-key/${pendingDeleteCredentialId}`, + { + data: { + password: pendingDeletePassword, + code: values.code + } } - }); + ); toast({ - description: t('securityKeyRemoveSuccess') + description: t("securityKeyRemoveSuccess") }); deleteForm.reset(); setSelectedSecurityKey(null); - setShow2FADialog(false); + setDialogState("list"); setPendingDeleteCredentialId(null); setPendingDeletePassword(null); await loadSecurityKeys(); } catch (error) { toast({ variant: "destructive", - description: formatAxiosError(error, t('securityKeyRemoveError')), + description: formatAxiosError( + error, + t("securityKeyRemoveError") + ) }); } finally { setDeleteInProgress(false); } }; + const handleRegister2FASubmit = async (values: { code: string }) => { + if (!pendingRegisterData) return; + + try { + setIsRegistering(true); + const startRes = await api.post( + "/auth/security-key/register/start", + { + name: pendingRegisterData.name, + password: pendingRegisterData.password, + code: values.code + } + ); + + const options = startRes.data.data; + + try { + const credential = await startRegistration(options); + + await api.post("/auth/security-key/register/verify", { + credential + }); + + toast({ + description: t("securityKeyRegisterSuccess", { + defaultValue: "Security key registered successfully" + }) + }); + + registerForm.reset(); + setDialogState("list"); + setPendingRegisterData(null); + setRegister2FAForm({ code: "" }); + await loadSecurityKeys(); + } catch (error: any) { + if (error.name === "NotAllowedError") { + if (error.message.includes("denied permission")) { + toast({ + variant: "destructive", + description: t("securityKeyPermissionDenied", { + defaultValue: + "Please allow access to your security key to continue registration." + }) + }); + } else { + toast({ + variant: "destructive", + description: t("securityKeyRemovedTooQuickly", { + defaultValue: + "Please keep your security key connected until the registration process completes." + }) + }); + } + } else if (error.name === "NotSupportedError") { + toast({ + variant: "destructive", + description: t("securityKeyNotSupported", { + defaultValue: + "Your security key may not be compatible. Please try a different security key." + }) + }); + } else { + toast({ + variant: "destructive", + description: t("securityKeyUnknownError", { + defaultValue: + "There was a problem registering your security key. Please try again." + }) + }); + } + throw error; // Re-throw to be caught by outer catch + } + } catch (error) { + console.error("Security key registration error:", error); + toast({ + variant: "destructive", + description: formatAxiosError( + error, + t("securityKeyRegisterError", { + defaultValue: "Failed to register security key" + }) + ) + }); + setRegister2FAForm({ code: "" }); + } finally { + setIsRegistering(false); + } + }; + const onOpenChange = (open: boolean) => { if (open) { loadSecurityKeys(); @@ -296,292 +434,426 @@ export default function SecurityKeyForm({ open, setOpen }: SecurityKeyFormProps) registerForm.reset(); deleteForm.reset(); setSelectedSecurityKey(null); - setShowRegisterDialog(false); + setDialogState("list"); + setPendingRegisterData(null); + setRegister2FAForm({ code: "" }); } setOpen(open); }; return ( <> - - - - - - {t('securityKeyManage')} - - - {t('securityKeyDescription')} - - + + + {dialogState === "list" && ( + <> + + + {t("securityKeyManage")} + + + {t("securityKeyDescription")} + + + +
+
+

+ {t("securityKeyList")} +

+ +
-
-
-

{t('securityKeyList')}

- -
+ {securityKeys.length > 0 ? ( +
+ {securityKeys.map((securityKey) => ( + + +
+
+ +
+
+

+ { + securityKey.name + } +

+

+ {t( + "securityKeyLastUsed", + { + date: new Date( + securityKey.lastUsed + ).toLocaleDateString() + } + )} +

+
+
+ +
+
+ ))} +
+ ) : ( +
+ +

+ {t("securityKeyNoKeysRegistered")} +

+

+ {t("securityKeyNoKeysDescription")} +

+
+ )} - {securityKeys.length > 0 ? ( -
- {securityKeys.map((securityKey) => ( - - -
-
- -
-
-

{securityKey.name}

-

- {t('securityKeyLastUsed', { - date: new Date(securityKey.lastUsed).toLocaleDateString() - })} -

-
-
- -
-
- ))} -
- ) : ( -
- -

No security keys registered

-

Add a security key to enhance your account security

-
- )} + {securityKeys.length === 1 && ( + + + + {t("securityKeyRecommendation")} + + + )} +
+ + + )} - {securityKeys.length === 1 && ( - - {t('securityKeyRecommendation')} - - )} -
-
-
- - - - - Register New Security Key - - Connect your security key and enter a name to identify it - - - -
- - ( - - {t('securityKeyNameLabel')} - - - - - - )} - /> - ( - - {t('password')} - - - - - - )} - /> - - + {dialogState === "register" && ( + <> + + + {t("securityKeyRegisterTitle")} + + + {t("securityKeyRegisterDescription")} + + + + + + ( + + + {t( + "securityKeyNameLabel" + )} + + + + + + + )} + /> + ( + + + {t("password")} + + + + + + + )} + /> + + + + + + + - - - - -
-
+ + + )} - !open && setSelectedSecurityKey(null)}> - - - - - Remove Security Key - - - Enter your password to remove the security key "{selectedSecurityKey?.name}" - - - -
- - ( - - {t('password')} - - - - - - )} - /> - - + {dialogState === "register2fa" && ( + <> + + + {t("securityKeyTwoFactorRequired")} + + + {t("securityKeyTwoFactorDescription")} + + + +
+
+ + + setRegister2FAForm({ + code: e.target.value + }) + } + maxLength={6} + disabled={isRegistering} + /> +
+
+
+ + + + + + + )} + + {dialogState === "delete" && ( + <> + + + {t("securityKeyRemoveTitle")} + + + {t("securityKeyRemoveDescription", { name: selectedSecurityKey!.name! })} + + + + + + ( + + + {t("password")} + + + + + + + )} + /> + + + + + + + -
- - -
-
+ + + )} - !open && setShow2FADialog(false)}> - - - Two-Factor Authentication Required - - Please enter your two-factor authentication code to remove the security key - - - -
- - ( - - Two-Factor Code - - - - - - )} - /> - - - + {dialogState === "delete2fa" && ( + <> + + + {t("securityKeyTwoFactorRequired")} + + + {t("securityKeyTwoFactorRemoveDescription")} + + + + + + ( + + + {t("securityKeyTwoFactorCode")} + + + + + + + )} + /> + + + + + + + - - - -
-
+ + + )} + + ); -} \ No newline at end of file +}