From 47916e312707cb7641a906734d1fc25097cdcf6c Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Wed, 27 Mar 2024 16:26:59 +0800 Subject: [PATCH] feat: add document auth 2FA --- .../sign/[token]/document-action-auth-2fa.tsx | 161 ++++++++++++++++++ .../[token]/document-action-auth-dialog.tsx | 8 + .../sign/[token]/document-auth-provider.tsx | 2 +- packages/lib/constants/document-auth.ts | 4 + .../document/is-recipient-authorized.ts | 18 ++ packages/lib/types/document-auth.ts | 22 ++- 6 files changed, 212 insertions(+), 3 deletions(-) create mode 100644 apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx new file mode 100644 index 000000000..61f280c1d --- /dev/null +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-2fa.tsx @@ -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; +}; + +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; + +export const DocumentActionAuth2FA = ({ + actionTarget = 'FIELD', + actionVerb = 'sign', + onReauthFormSubmit, + open, + onOpenChange, +}: DocumentActionAuth2FAProps) => { + const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = + useRequiredDocumentAuthContext(); + + const form = useForm({ + resolver: zodResolver(Z2FAAuthFormSchema), + defaultValues: { + token: '', + }, + }); + + const [formErrorCode, setFormErrorCode] = useState(null); + + const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { + try { + setIsCurrentlyAuthenticating(true); + + await onReauthFormSubmit({ + type: DocumentAuth.TWO_FACTOR_AUTH, + 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 ( +
+ + + {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()}.`} + + + + + + + + +
+ ); + } + + return ( +
+ +
+
+ ( + + 2FA token + + + + + + + + )} + /> + + {formErrorCode && ( + + Unauthorized + + We were unable to verify your details. Please try again or contact support + + + )} + + + + + + +
+
+
+ + ); +}; diff --git a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx index bf4809c8d..0aed60be0 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-action-auth-dialog.tsx @@ -14,6 +14,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'; @@ -74,6 +75,13 @@ export const DocumentActionAuthDialog = ({ onReauthFormSubmit={onReauthFormSubmit} /> )) + .with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => ( + + )) .with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null) .exhaustive()} diff --git a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx index 48e7b44d9..f9eda2564 100644 --- a/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx +++ b/apps/web/src/app/(signing)/sign/[token]/document-auth-provider.tsx @@ -142,7 +142,7 @@ export const DocumentAuthProvider = ({ .with(DocumentAuth.EXPLICIT_NONE, () => ({ type: DocumentAuth.EXPLICIT_NONE, })) - .with(DocumentAuth.PASSKEY, null, () => null) + .with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null) .exhaustive(); const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => { diff --git a/packages/lib/constants/document-auth.ts b/packages/lib/constants/document-auth.ts index af40a45df..7881c38f5 100644 --- a/packages/lib/constants/document-auth.ts +++ b/packages/lib/constants/document-auth.ts @@ -24,6 +24,10 @@ export const DOCUMENT_AUTH_TYPES: Record = { key: DocumentAuth.PASSKEY, value: 'Require passkey', }, + [DocumentAuth.TWO_FACTOR_AUTH]: { + key: DocumentAuth.TWO_FACTOR_AUTH, + value: 'Require 2FA', + }, [DocumentAuth.EXPLICIT_NONE]: { key: DocumentAuth.EXPLICIT_NONE, value: 'None (Overrides global settings)', diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 1f17fda3b..5da50d6c7 100644 --- a/packages/lib/server-only/document/is-recipient-authorized.ts +++ b/packages/lib/server-only/document/is-recipient-authorized.ts @@ -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'; @@ -96,6 +97,23 @@ export const isRecipientAuthorized = async ({ tokenReference, }); }) + .with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => { + 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(); }; diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index d44a17bb0..eccd119eb 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -5,7 +5,12 @@ 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', + 'TWO_FACTOR_AUTH', + 'EXPLICIT_NONE', +]); export const DocumentAuth = ZDocumentAuthTypesSchema.Enum; const ZDocumentAuthAccountSchema = z.object({ @@ -22,6 +27,11 @@ const ZDocumentAuthPasskeySchema = z.object({ tokenReference: z.string().min(1), }); +const ZDocumentAuth2FASchema = z.object({ + type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), + token: z.string().min(4).max(10), +}); + /** * All the document auth methods for both accessing and actioning. */ @@ -29,6 +39,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, ZDocumentAuthExplicitNoneSchema, ZDocumentAuthPasskeySchema, + ZDocumentAuth2FASchema, ]); /** @@ -47,8 +58,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.TWO_FACTOR_AUTH, ]); -export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]); /** * The recipient access auth methods. @@ -68,11 +84,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.TWO_FACTOR_AUTH, DocumentAuth.EXPLICIT_NONE, ]);