feat: add 2FA document auth

This commit is contained in:
David Nguyen
2024-03-24 16:34:00 +08:00
parent fd881572f8
commit 55e1c1afd0
6 changed files with 213 additions and 3 deletions

View File

@ -0,0 +1,161 @@
import { useEffect, useState } from 'react';
import Link from 'next/link';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { RecipientRole } from '@documenso/prisma/client';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog';
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
export type DocumentActionAuth2FAProps = {
actionTarget?: 'FIELD' | 'DOCUMENT';
actionVerb?: string;
open: boolean;
onOpenChange: (value: boolean) => void;
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
};
const Z2FAAuthFormSchema = z.object({
token: z
.string()
.min(4, { message: 'Token must at least 4 characters long' })
.max(10, { message: 'Token must be at most 10 characters long' }),
});
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
export const DocumentActionAuth2FA = ({
actionTarget = 'FIELD',
actionVerb = 'sign',
onReauthFormSubmit,
open,
onOpenChange,
}: DocumentActionAuth2FAProps) => {
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentAuthContext();
const form = useForm<T2FAAuthFormSchema>({
resolver: zodResolver(Z2FAAuthFormSchema),
defaultValues: {
token: '',
},
});
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
try {
setIsCurrentlyAuthenticating(true);
await onReauthFormSubmit({
type: DocumentAuth['2FA'],
token,
});
setIsCurrentlyAuthenticating(false);
onOpenChange(false);
} catch (err) {
setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err);
setFormErrorCode(error.code);
// Todo: Alert.
}
};
useEffect(() => {
form.reset({
token: '',
});
setFormErrorCode(null);
}, [open, form]);
if (!user?.twoFactorEnabled) {
return (
<div className="space-y-4">
<Alert variant="warning">
<AlertDescription>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
? 'You need to setup 2FA to mark this document as viewed.'
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
</AlertDescription>
</Alert>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Close
</Button>
<Button type="button" asChild>
<Link href="/settings/security">Setup 2FA</Link>
</Button>
</DialogFooter>
</div>
);
}
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}>
<div className="space-y-4">
<FormField
control={form.control}
name="token"
render={({ field }) => (
<FormItem>
<FormLabel required>2FA token</FormLabel>
<FormControl>
<Input {...field} placeholder="Token" />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{formErrorCode && (
<Alert variant="destructive">
<AlertTitle>Unauthorized</AlertTitle>
<AlertDescription>
We were unable to verify your details. Please try again or contact support
</AlertDescription>
</Alert>
)}
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button type="submit" loading={isCurrentlyAuthenticating}>
Sign
</Button>
</DialogFooter>
</div>
</fieldset>
</form>
</Form>
);
};

View File

@ -16,6 +16,7 @@ import {
DialogTitle,
} from '@documenso/ui/primitives/dialog';
import { DocumentActionAuth2FA } from './document-action-auth-2fa';
import { DocumentActionAuthAccount } from './document-action-auth-account';
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
import { useRequiredDocumentAuthContext } from './document-auth-provider';
@ -102,6 +103,15 @@ export const DocumentActionAuthDialog = ({
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth['2FA'] }, () => (
<DocumentActionAuth2FA
actionTarget={actionTarget}
actionVerb={actionVerb}
open={open}
onOpenChange={onOpenChange}
onReauthFormSubmit={onReauthFormSubmit}
/>
))
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
.exhaustive()}
</DialogContent>

View File

@ -136,7 +136,7 @@ export const DocumentAuthProvider = ({
.with(DocumentAuth.EXPLICIT_NONE, () => ({
type: DocumentAuth.EXPLICIT_NONE,
}))
.with(DocumentAuth.PASSKEY, null, () => null)
.with(DocumentAuth.PASSKEY, DocumentAuth['2FA'], null, () => null)
.exhaustive();
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {

View File

@ -24,6 +24,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
key: DocumentAuth.PASSKEY,
value: 'Require passkey',
},
[DocumentAuth['2FA']]: {
key: DocumentAuth['2FA'],
value: 'Require 2FA',
},
[DocumentAuth.EXPLICIT_NONE]: {
key: DocumentAuth.EXPLICIT_NONE,
value: 'None (Overrides global settings)',

View File

@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
import { prisma } from '@documenso/prisma';
import type { Document, Recipient } from '@documenso/prisma/client';
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
import { DocumentAuth } from '../../types/document-auth';
@ -104,6 +105,27 @@ export const isRecipientAuthorized = async ({
tokenReference,
});
})
.with({ type: DocumentAuth['2FA'] }, async ({ token }) => {
if (!userId) {
return false;
}
const user = await prisma.user.findFirst({
where: {
id: userId,
},
});
// Should not be possible.
if (!user) {
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
}
return await verifyTwoFactorAuthenticationToken({
user,
totpCode: token,
});
})
.exhaustive();
};

View File

@ -5,7 +5,7 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
/**
* All the available types of document authentication options for both access and action.
*/
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'EXPLICIT_NONE']);
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', '2FA', 'EXPLICIT_NONE']);
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
const ZDocumentAuthAccountSchema = z.object({
@ -22,6 +22,11 @@ const ZDocumentAuthPasskeySchema = z.object({
tokenReference: z.string().min(1),
});
const ZDocumentAuth2FASchema = z.object({
type: z.literal(DocumentAuth['2FA']),
token: z.string().min(4).max(10),
});
/**
* All the document auth methods for both accessing and actioning.
*/
@ -29,6 +34,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthExplicitNoneSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]);
/**
@ -47,8 +53,13 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
]);
export const ZDocumentActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth['2FA'],
]);
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]);
/**
* The recipient access auth methods.
@ -68,11 +79,13 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
ZDocumentAuthAccountSchema,
ZDocumentAuthPasskeySchema,
ZDocumentAuth2FASchema,
ZDocumentAuthExplicitNoneSchema,
]);
export const ZRecipientActionAuthTypesSchema = z.enum([
DocumentAuth.ACCOUNT,
DocumentAuth.PASSKEY,
DocumentAuth['2FA'],
DocumentAuth.EXPLICIT_NONE,
]);