feat: add passkeys (#989)

## Description

Add support to login with passkeys.

Passkeys can be added via the user security settings page.

Note: Currently left out adding the type of authentication method for
the 'user security audit logs' because we're using the `signIn`
next-auth event which doesn't appear to provide the context. Will look
into it at another time.

## Changes Made

- Add passkeys to login
- Add passkeys feature flag
- Add page to manage passkeys
- Add audit logs relating to passkeys
- Updated prisma schema to support passkeys & anonymous verification
tokens

## Testing Performed

To be done.

MacOS:
- Safari  
- Chrome  
- Firefox 

Windows:
- Chrome [Untested] 
- Firefox [Untested]

Linux:
- Chrome [Untested]
- Firefox [Untested]

iOS:
- Safari 

## Checklist

<!--- Please check the boxes that apply to this pull request. -->
<!--- You can add or remove items as needed. -->

- [X] I have tested these changes locally and they work as expected.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Introduced Passkey authentication, including creation, sign-in, and
management of passkeys.
- Added a Passkeys section in Security Settings for managing user
passkeys.
- Implemented UI updates for Passkey authentication, including a new
dialog for creating passkeys and a data table for managing them.
- Enhanced security settings with server-side feature flags to
conditionally display new security features.
- **Bug Fixes**
	- Improved UI consistency in the Settings Security Activity Page.
- Updated button styling in the 2FA Recovery Codes component for better
visibility.
- **Refactor**
- Streamlined authentication options to include WebAuthn credentials
provider.
- **Chores**
- Updated database schema to support passkeys and related functionality.
	- Added new audit log types for passkey-related activities.
- Enhanced server-only authentication utilities for passkey registration
and management.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
David Nguyen
2024-03-26 21:11:59 +08:00
committed by GitHub
parent 3eddfcc805
commit 5210fe2963
25 changed files with 1706 additions and 27 deletions

View File

@ -5,11 +5,18 @@ import { cn } from '@documenso/ui/lib/utils';
export type SettingsHeaderProps = {
title: string;
subtitle: string;
hideDivider?: boolean;
children?: React.ReactNode;
className?: string;
};
export const SettingsHeader = ({ children, title, subtitle, className }: SettingsHeaderProps) => {
export const SettingsHeader = ({
children,
title,
subtitle,
className,
hideDivider,
}: SettingsHeaderProps) => {
return (
<>
<div className={cn('flex flex-row items-center justify-between', className)}>
@ -22,7 +29,7 @@ export const SettingsHeader = ({ children, title, subtitle, className }: Setting
{children}
</div>
<hr className="my-4" />
{!hideDivider && <hr className="my-4" />}
</>
);
};

View File

@ -6,12 +6,18 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { zodResolver } from '@hookform/resolvers/zod';
import { browserSupportsWebAuthn, startAuthentication } from '@simplewebauthn/browser';
import { KeyRoundIcon } from 'lucide-react';
import { signIn } from 'next-auth/react';
import { useForm } from 'react-hook-form';
import { FcGoogle } from 'react-icons/fc';
import { match } from 'ts-pattern';
import { z } from 'zod';
import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
import { trpc } from '@documenso/trpc/react';
import { ZCurrentPasswordSchema } from '@documenso/trpc/server/auth-router/schema';
import { cn } from '@documenso/ui/lib/utils';
import { Button } from '@documenso/ui/primitives/button';
@ -66,14 +72,24 @@ export type SignInFormProps = {
export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: SignInFormProps) => {
const { toast } = useToast();
const { getFlag } = useFeatureFlags();
const router = useRouter();
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
useState(false);
const router = useRouter();
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
'totp' | 'backup'
>('totp');
const [isPasskeyLoading, setIsPasskeyLoading] = useState(false);
const isPasskeyEnabled = getFlag('app_passkey');
const { mutateAsync: createPasskeySigninOptions } =
trpc.auth.createPasskeySigninOptions.useMutation();
const form = useForm<TSignInFormSchema>({
values: {
email: initialEmail ?? '',
@ -107,6 +123,63 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
setTwoFactorAuthenticationMethod(method);
};
const onSignInWithPasskey = async () => {
if (!browserSupportsWebAuthn) {
toast({
title: 'Not supported',
description: 'Passkeys are not supported on this browser',
duration: 10000,
variant: 'destructive',
});
return;
}
try {
setIsPasskeyLoading(true);
const options = await createPasskeySigninOptions();
const credential = await startAuthentication(options);
const result = await signIn('webauthn', {
credential: JSON.stringify(credential),
callbackUrl: LOGIN_REDIRECT_PATH,
redirect: false,
});
if (!result?.url || result.error) {
throw new AppError(result?.error ?? '');
}
window.location.href = result.url;
} catch (err) {
setIsPasskeyLoading(false);
if (err.name === 'NotAllowedError') {
return;
}
const error = AppError.parseError(err);
const errorMessage = match(error.code)
.with(
AppErrorCode.NOT_SETUP,
() =>
'This passkey is not configured for this application. Please login and add one in the user settings.',
)
.with(AppErrorCode.EXPIRED_CODE, () => 'This session has expired. Please try again.')
.otherwise(() => 'Please try again later or login using your normal details');
toast({
title: 'Something went wrong',
description: errorMessage,
duration: 10000,
variant: 'destructive',
});
}
};
const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => {
try {
const credentials: Record<string, string> = {
@ -189,7 +262,10 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
className={cn('flex w-full flex-col gap-y-4', className)}
onSubmit={form.handleSubmit(onFormSubmit)}
>
<fieldset className="flex w-full flex-col gap-y-4" disabled={isSubmitting}>
<fieldset
className="flex w-full flex-col gap-y-4"
disabled={isSubmitting || isPasskeyLoading}
>
<FormField
control={form.control}
name="email"
@ -217,6 +293,8 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
<PasswordInput {...field} />
</FormControl>
<FormMessage />
<p className="mt-2 text-right">
<Link
href="/forgot-password"
@ -225,29 +303,28 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
Forgot your password?
</Link>
</p>
<FormMessage />
</FormItem>
)}
/>
</fieldset>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
<Button
type="submit"
size="lg"
loading={isSubmitting}
className="dark:bg-documenso dark:hover:opacity-90"
>
{isSubmitting ? 'Signing in...' : 'Sign In'}
</Button>
{isGoogleSSOEnabled && (
<>
{(isGoogleSSOEnabled || isPasskeyEnabled) && (
<div className="relative flex items-center justify-center gap-x-4 py-2 text-xs uppercase">
<div className="bg-border h-px flex-1" />
<span className="text-muted-foreground bg-transparent">Or continue with</span>
<div className="bg-border h-px flex-1" />
</div>
)}
{isGoogleSSOEnabled && (
<Button
type="button"
size="lg"
@ -259,8 +336,23 @@ export const SignInForm = ({ className, initialEmail, isGoogleSSOEnabled }: Sign
<FcGoogle className="mr-2 h-5 w-5" />
Google
</Button>
</>
)}
)}
{isPasskeyEnabled && (
<Button
type="button"
size="lg"
variant="outline"
disabled={isSubmitting}
loading={isPasskeyLoading}
className="bg-background text-muted-foreground border"
onClick={onSignInWithPasskey}
>
{!isPasskeyLoading && <KeyRoundIcon className="-ml-1 mr-1 h-5 w-5" />}
Passkey
</Button>
)}
</fieldset>
</form>
<Dialog