diff --git a/messages/en-US.json b/messages/en-US.json index 36927b51..9e53388d 100644 --- a/messages/en-US.json +++ b/messages/en-US.json @@ -1149,5 +1149,6 @@ "passkeyRemoveError": "Failed to remove passkey", "passkeyLoadError": "Failed to load passkeys", "passkeyLogin": "Login with Passkey", - "passkeyAuthError": "Failed to authenticate with passkey" + "passkeyAuthError": "Failed to authenticate with passkey", + "passkeyRecommendation": "Consider registering another passkey on a different device to ensure you don't get locked out of your account." } diff --git a/server/routers/auth/passkey.ts b/server/routers/auth/passkey.ts index ebe1a4e5..07080fc6 100644 --- a/server/routers/auth/passkey.ts +++ b/server/routers/auth/passkey.ts @@ -22,8 +22,14 @@ import type { VerifiedRegistrationResponse, VerifiedAuthenticationResponse } from "@simplewebauthn/server"; +import type { + AuthenticatorTransport, + PublicKeyCredentialDescriptorJSON +} from "@simplewebauthn/types"; import config from "@server/lib/config"; import { UserType } from "@server/types/UserTypes"; +import { verifyPassword } from "@server/auth/password"; +import { unauthorized } from "@server/auth/unauthorizedResponse"; // The RP ID is the domain name of your application const rpID = new URL(config.getRawConfig().app.dashboard_url).hostname; @@ -89,7 +95,8 @@ async function clearChallenge(sessionId: string) { } export const registerPasskeyBody = z.object({ - name: z.string().min(1) + name: z.string().min(1), + password: z.string().min(1) }).strict(); export const verifyRegistrationBody = z.object({ @@ -104,6 +111,10 @@ export const verifyAuthenticationBody = z.object({ credential: z.any() }).strict(); +export const deletePasskeyBody = z.object({ + password: z.string().min(1) +}).strict(); + export async function startRegistration( req: Request, res: Response, @@ -120,7 +131,7 @@ export async function startRegistration( ); } - const { name } = parsedBody.data; + const { name, password } = parsedBody.data; const user = req.user as User; // Only allow internal users to use passkeys @@ -134,6 +145,23 @@ export async function startRegistration( } try { + // Verify password + const validPassword = await verifyPassword(password, user.passwordHash!); + if (!validPassword) { + return next(unauthorized()); + } + + // If user has 2FA enabled, require a code + if (user.twoFactorEnabled) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + // Get existing passkeys for user const existingPasskeys = await db .select() @@ -141,10 +169,10 @@ export async function startRegistration( .where(eq(passkeys.userId, user.userId)); const excludeCredentials = existingPasskeys.map(key => ({ - id: Buffer.from(key.credentialId, 'base64'), + id: Buffer.from(key.credentialId, 'base64').toString('base64url'), type: 'public-key' as const, - transports: key.transports ? JSON.parse(key.transports) : undefined - })); + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined + } satisfies PublicKeyCredentialDescriptorJSON)); const options: GenerateRegistrationOptionsOpts = { rpName, @@ -164,11 +192,11 @@ export async function startRegistration( // Store challenge in database await storeChallenge(req.session.sessionId, registrationOptions.challenge, name, user.userId); - return response(res, { + return response(res, { data: registrationOptions, success: true, error: false, - message: "Registration options generated", + message: "Registration options generated successfully", status: HttpCode.OK }); } catch (error) { @@ -176,7 +204,7 @@ export async function startRegistration( return next( createHttpError( HttpCode.INTERNAL_SERVER_ERROR, - "Failed to generate registration options" + "Failed to start registration" ) ); } @@ -326,6 +354,19 @@ export async function deletePasskey( const credentialId = decodeURIComponent(encodedCredentialId); const user = req.user as User; + const parsedBody = deletePasskeyBody.safeParse(req.body); + + if (!parsedBody.success) { + return next( + createHttpError( + HttpCode.BAD_REQUEST, + fromError(parsedBody.error).toString() + ) + ); + } + + const { password } = parsedBody.data; + // Only allow internal users to use passkeys if (user.type !== UserType.Internal) { return next( @@ -337,6 +378,23 @@ export async function deletePasskey( } try { + // Verify password + const validPassword = await verifyPassword(password, user.passwordHash!); + if (!validPassword) { + return next(unauthorized()); + } + + // If user has 2FA enabled, require a code + if (user.twoFactorEnabled) { + return response<{ codeRequested: boolean }>(res, { + data: { codeRequested: true }, + success: true, + error: false, + message: "Two-factor authentication required", + status: HttpCode.ACCEPTED + }); + } + await db .delete(passkeys) .where(and( @@ -424,7 +482,7 @@ export async function startAuthentication( allowCredentials = userPasskeys.map(key => ({ id: Buffer.from(key.credentialId, 'base64'), type: 'public-key' as const, - transports: key.transports ? JSON.parse(key.transports) : undefined + transports: key.transports ? JSON.parse(key.transports) as AuthenticatorTransport[] : undefined })); } else { // If no email provided, allow any passkey (for resident key authentication) @@ -546,7 +604,7 @@ export async function verifyAuthentication( credentialID: Buffer.from(passkey.credentialId, 'base64'), credentialPublicKey: Buffer.from(passkey.publicKey, 'base64'), counter: passkey.signCount, - transports: passkey.transports ? JSON.parse(passkey.transports) : undefined + transports: passkey.transports ? JSON.parse(passkey.transports) as AuthenticatorTransport[] : undefined }, requireUserVerification: false }); diff --git a/src/components/PasskeyForm.tsx b/src/components/PasskeyForm.tsx index 66abc844..14c3f393 100644 --- a/src/components/PasskeyForm.tsx +++ b/src/components/PasskeyForm.tsx @@ -44,21 +44,41 @@ type Passkey = { lastUsed: string; }; +type DeletePasskeyData = { + credentialId: string; + name: string; +}; + export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { const [loading, setLoading] = useState(false); const [passkeys, setPasskeys] = useState([]); + const [step, setStep] = useState<"list" | "register" | "delete">("list"); + const [selectedPasskey, setSelectedPasskey] = useState(null); const { user } = useUserContext(); const t = useTranslations(); const api = createApiClient(useEnvContext()); const registerSchema = z.object({ - name: z.string().min(1, { message: t('passkeyNameRequired') }) + name: z.string().min(1, { message: t('passkeyNameRequired') }), + password: z.string().min(1, { message: t('passwordRequired') }) }); - const form = useForm>({ + const deleteSchema = z.object({ + password: z.string().min(1, { message: t('passwordRequired') }) + }); + + const registerForm = useForm>({ resolver: zodResolver(registerSchema), defaultValues: { - name: "" + name: "", + password: "" + } + }); + + const deleteForm = useForm>({ + resolver: zodResolver(deleteSchema), + defaultValues: { + password: "" } }); @@ -87,8 +107,21 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { // Start registration const startRes = await api.post("/auth/passkey/register/start", { - name: values.name + name: values.name, + password: values.password }); + + // Handle 2FA if required + if (startRes.data.data.codeRequested) { + // TODO: Handle 2FA verification + toast({ + title: "2FA Required", + description: "Two-factor authentication is required to register a passkey.", + variant: "destructive" + }); + return; + } + const options = startRes.data.data; // Create passkey @@ -104,9 +137,12 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { description: t('passkeyRegisterSuccess') }); + // Reset form and go back to list + registerForm.reset(); + setStep("list"); + // Reload passkeys await loadPasskeys(); - form.reset(); } catch (error) { toast({ title: "Error", @@ -118,17 +154,26 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { } }; - const handleDeletePasskey = async (credentialId: string) => { + const handleDeletePasskey = async (values: z.infer) => { + if (!selectedPasskey) return; + try { setLoading(true); - const encodedCredentialId = encodeURIComponent(credentialId); - await api.delete(`/auth/passkey/${encodedCredentialId}`); + const encodedCredentialId = encodeURIComponent(selectedPasskey.credentialId); + await api.delete(`/auth/passkey/${encodedCredentialId}`, { + data: { password: values.password } + }); toast({ title: "Success", description: t('passkeyRemoveSuccess') }); + // Reset form and go back to list + deleteForm.reset(); + setStep("list"); + setSelectedPasskey(null); + // Reload passkeys await loadPasskeys(); } catch (error) { @@ -142,90 +187,225 @@ export default function PasskeyForm({ open, setOpen }: PasskeyFormProps) { } }; + function reset() { + registerForm.reset(); + deleteForm.reset(); + setStep("list"); + setSelectedPasskey(null); + setLoading(false); + } + return ( - - - - {t('passkeyManage')} - + { + setOpen(val); + if (!val) reset(); + }} + > + + + {t('passkeyManage')} + {t('passkeyDescription')} - -
-
-

{t('passkeyList')}

- {passkeys.length === 0 ? ( -

- {t('passkeyNone')} -

- ) : ( -
- {passkeys.map((passkey) => ( -
-
-

{passkey.name}

-

- {t('passkeyLastUsed', { - date: new Date(passkey.lastUsed).toLocaleDateString() - })} -

-
+ +
+ {step === "list" && ( + <> +
+

{t('passkeyList')}

+ {passkeys.length === 0 ? ( +
+

+ {t('passkeyNone')} +

+
+ ) : ( +
+ {passkeys.map((passkey) => ( +
+
+

{passkey.name}

+

+ {t('passkeyLastUsed', { + date: new Date(passkey.lastUsed).toLocaleDateString() + })} +

+
+ +
+ ))} + {passkeys.length === 1 && ( +
+ {t('passkeyRecommendation')} +
+ )} +
+ )} +
+ +
+ +
+ + )} + + {step === "register" && ( +
+

{t('passkeyRegister')}

+
+ + ( + + {t('passkeyNameLabel')} + + + + + + )} + /> + + ( + + {t('password')} + + + + + + )} + /> + +
+ +
+ + +
+ )} + + {step === "delete" && selectedPasskey && ( +
+
+

Remove Passkey

+

+ Enter your password to remove the passkey "{selectedPasskey.name}" +

+
+ +
+ + ( + + {t('password')} + + + + + + )} + /> + +
+ +
- ))} -
- )} -
- -
-

{t('passkeyRegister')}

- - - ( - - {t('passkeyNameLabel')} - - - - - - )} - /> - - - -
+ + +
+ )}
- + - +