From 995bc9c362ccbc45c89517e0b4208836a3649e8e Mon Sep 17 00:00:00 2001 From: Lucas Smith Date: Mon, 6 Oct 2025 16:17:54 +1100 Subject: [PATCH] feat: support 2fa for document completion (#2063) Adds support for 2FA when completing a document, also adds support for using email for 2FA when no authenticator has been associated with the account. --- .gitignore | 5 +- .../direct-template-signing-form.tsx | 4 +- .../document-signing/access-auth-2fa-form.tsx | 312 +++++++++++++ .../document-signing-complete-dialog.tsx | 431 ++++++++++-------- .../document-signing-form.tsx | 31 +- .../document-signing-page-view.tsx | 29 +- .../_internal+/[__htmltopdf]+/certificate.tsx | 1 + .../routes/_recipient+/d.$token+/_index.tsx | 10 +- .../_recipient+/sign.$token+/_index.tsx | 17 +- .../app/routes/embed+/_v0+/direct.$url.tsx | 10 +- .../app/routes/embed+/_v0+/sign.$url.tsx | 10 +- .../template-access-auth-2fa.tsx | 60 +++ packages/email/templates/access-auth-2fa.tsx | 77 ++++ packages/lib/errors/app-error.ts | 2 + .../lib/server-only/2fa/email/constants.ts | 1 + .../generate-2fa-credentials-from-email.ts | 38 ++ .../email/generate-2fa-token-from-email.ts | 23 + .../2fa/email/send-2fa-token-email.ts | 124 +++++ .../email/validate-2fa-token-from-email.ts | 37 ++ .../document/complete-document-with-token.ts | 70 ++- .../document/is-recipient-authorized.ts | 58 ++- .../document/validate-field-auth.ts | 2 +- .../create-document-from-direct-template.ts | 2 + packages/lib/types/document-audit-logs.ts | 44 ++ packages/lib/types/document-auth.ts | 11 +- packages/lib/utils/document-audit-logs.ts | 30 ++ .../access-auth-request-2fa-email.ts | 94 ++++ .../access-auth-request-2fa-email.types.ts | 17 + .../trpc/server/document-router/router.ts | 5 + .../trpc/server/recipient-router/router.ts | 3 +- .../trpc/server/recipient-router/schema.ts | 2 + 31 files changed, 1300 insertions(+), 260 deletions(-) create mode 100644 apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx create mode 100644 packages/email/template-components/template-access-auth-2fa.tsx create mode 100644 packages/email/templates/access-auth-2fa.tsx create mode 100644 packages/lib/server-only/2fa/email/constants.ts create mode 100644 packages/lib/server-only/2fa/email/generate-2fa-credentials-from-email.ts create mode 100644 packages/lib/server-only/2fa/email/generate-2fa-token-from-email.ts create mode 100644 packages/lib/server-only/2fa/email/send-2fa-token-email.ts create mode 100644 packages/lib/server-only/2fa/email/validate-2fa-token-from-email.ts create mode 100644 packages/trpc/server/document-router/access-auth-request-2fa-email.ts create mode 100644 packages/trpc/server/document-router/access-auth-request-2fa-email.types.ts diff --git a/.gitignore b/.gitignore index f31f951a7..9e622a76f 100644 --- a/.gitignore +++ b/.gitignore @@ -56,4 +56,7 @@ logs.json # claude .claude -CLAUDE.md \ No newline at end of file +CLAUDE.md + +# agents +.specs diff --git a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx index 8f78ae754..fedea33c0 100644 --- a/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx +++ b/apps/remix/app/components/general/direct-template/direct-template-signing-form.tsx @@ -417,11 +417,11 @@ export const DirectTemplateSigningForm = ({ handleSubmit()} documentTitle={template.title} fields={localFields} fieldsValidated={fieldsValidated} - role={directRecipient.role} + recipient={directRecipient} /> diff --git a/apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx b/apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx new file mode 100644 index 000000000..57891e877 --- /dev/null +++ b/apps/remix/app/components/general/document-signing/access-auth-2fa-form.tsx @@ -0,0 +1,312 @@ +import { useEffect, useState } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; +import { Trans } from '@lingui/react/macro'; +import { ArrowLeftIcon, KeyIcon, MailIcon } from 'lucide-react'; +import { DateTime } from 'luxon'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; +import { trpc } from '@documenso/trpc/react'; +import { cn } from '@documenso/ui/lib/utils'; +import { Alert } from '@documenso/ui/primitives/alert'; +import { Button } from '@documenso/ui/primitives/button'; +import { Form, FormField, FormItem } from '@documenso/ui/primitives/form/form'; +import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + +type FormStep = 'method-selection' | 'code-input'; +type TwoFactorMethod = 'email' | 'authenticator'; + +const ZAccessAuth2FAFormSchema = z.object({ + token: z.string().length(6, { message: 'Token must be 6 characters long' }), +}); + +type TAccessAuth2FAFormSchema = z.infer; + +export type AccessAuth2FAFormProps = { + onSubmit: (accessAuthOptions: TRecipientAccessAuth) => void; + token: string; + error?: string | null; +}; + +export const AccessAuth2FAForm = ({ onSubmit, token, error }: AccessAuth2FAFormProps) => { + const [step, setStep] = useState('method-selection'); + const [selectedMethod, setSelectedMethod] = useState(null); + + const [expiresAt, setExpiresAt] = useState(null); + const [millisecondsRemaining, setMillisecondsRemaining] = useState(null); + + const { _ } = useLingui(); + const { toast } = useToast(); + + const { user } = useRequiredDocumentSigningAuthContext(); + + const { mutateAsync: request2FAEmail, isPending: isRequesting2FAEmail } = + trpc.document.accessAuth.request2FAEmail.useMutation(); + + const form = useForm({ + resolver: zodResolver(ZAccessAuth2FAFormSchema), + defaultValues: { + token: '', + }, + }); + + const hasAuthenticatorEnabled = user?.twoFactorEnabled === true; + + const onMethodSelect = async (method: TwoFactorMethod) => { + setSelectedMethod(method); + + if (method === 'email') { + try { + const result = await request2FAEmail({ + token: token, + }); + + setExpiresAt(result.expiresAt); + setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now()); + + setStep('code-input'); + } catch (error) { + toast({ + title: _(msg`An error occurred`), + description: _( + msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`, + ), + variant: 'destructive', + }); + + return; + } + } + + setStep('code-input'); + }; + + const onFormSubmit = (data: TAccessAuth2FAFormSchema) => { + if (!selectedMethod) { + return; + } + + // Prepare the auth options for the completion attempt + const accessAuthOptions: TRecipientAccessAuth = { + type: 'TWO_FACTOR_AUTH', + token: data.token, // Just the user's code - backend will validate using method type + method: selectedMethod, + }; + + onSubmit(accessAuthOptions); + }; + + const onGoBack = () => { + setStep('method-selection'); + setSelectedMethod(null); + setExpiresAt(null); + setMillisecondsRemaining(null); + }; + + const onResendEmail = async () => { + if (selectedMethod !== 'email') { + return; + } + + try { + const result = await request2FAEmail({ + token: token, + }); + + setExpiresAt(result.expiresAt); + setMillisecondsRemaining(result.expiresAt.valueOf() - Date.now()); + } catch (error) { + toast({ + title: _(msg`An error occurred`), + description: _( + msg`We encountered an unknown error while attempting to request the two-factor authentication code. Please try again later.`, + ), + variant: 'destructive', + }); + } + }; + + useEffect(() => { + const interval = setInterval(() => { + if (expiresAt) { + setMillisecondsRemaining(expiresAt.valueOf() - Date.now()); + } + }, 1000); + + return () => clearInterval(interval); + }, [expiresAt]); + + return ( +
+ {step === 'method-selection' && ( +
+
+

+ Choose verification method +

+

+ Please select how you'd like to receive your verification code. +

+
+ + {error && ( + + {error} + + )} + +
+ + + {hasAuthenticatorEnabled && ( + + )} +
+
+ )} + + {step === 'code-input' && ( +
+
+ + +

+ Enter verification code +

+
+ +
+ {selectedMethod === 'email' ? ( + + We've sent a 6-digit verification code to your email. Please enter it below to + complete the document. + + ) : ( + + Please open your authenticator app and enter the 6-digit code for this document. + + )} +
+ +
+ +
+ ( + + + + + + + + + + + + + {expiresAt && millisecondsRemaining !== null && ( +
+ + Expires in{' '} + {DateTime.fromMillis(Math.max(millisecondsRemaining, 0)).toFormat( + 'mm:ss', + )} + +
+ )} +
+ )} + /> + +
+ + + {selectedMethod === 'email' && ( + + )} +
+
+
+ +
+ )} +
+ ); +}; diff --git a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx index 65503b965..7f0f06e5a 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-complete-dialog.tsx @@ -2,12 +2,17 @@ import { useMemo, useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { Trans } from '@lingui/react/macro'; -import type { Field } from '@prisma/client'; +import type { Field, Recipient } from '@prisma/client'; import { RecipientRole } from '@prisma/client'; import { useForm } from 'react-hook-form'; import { match } from 'ts-pattern'; import { z } from 'zod'; +import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error'; +import { + type TRecipientAccessAuth, + ZDocumentAccessAuthSchema, +} from '@documenso/lib/types/document-auth'; import { fieldsContainUnsignedRequiredField } from '@documenso/lib/utils/advanced-fields-helpers'; import { Button } from '@documenso/ui/primitives/button'; import { @@ -27,15 +32,21 @@ import { } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; +import { AccessAuth2FAForm } from '~/components/general/document-signing/access-auth-2fa-form'; import { DocumentSigningDisclosure } from '~/components/general/document-signing/document-signing-disclosure'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; + export type DocumentSigningCompleteDialogProps = { isSubmitting: boolean; documentTitle: string; fields: Field[]; fieldsValidated: () => void | Promise; - onSignatureComplete: (nextSigner?: { name: string; email: string }) => void | Promise; - role: RecipientRole; + onSignatureComplete: ( + nextSigner?: { name: string; email: string }, + accessAuthOptions?: TRecipientAccessAuth, + ) => void | Promise; + recipient: Pick; disabled?: boolean; allowDictateNextSigner?: boolean; defaultNextSigner?: { @@ -47,6 +58,7 @@ export type DocumentSigningCompleteDialogProps = { const ZNextSignerFormSchema = z.object({ name: z.string().min(1, 'Name is required'), email: z.string().email('Invalid email address'), + accessAuthOptions: ZDocumentAccessAuthSchema.optional(), }); type TNextSignerFormSchema = z.infer; @@ -57,7 +69,7 @@ export const DocumentSigningCompleteDialog = ({ fields, fieldsValidated, onSignatureComplete, - role, + recipient, disabled = false, allowDictateNextSigner = false, defaultNextSigner, @@ -65,6 +77,11 @@ export const DocumentSigningCompleteDialog = ({ const [showDialog, setShowDialog] = useState(false); const [isEditingNextSigner, setIsEditingNextSigner] = useState(false); + const [showTwoFactorForm, setShowTwoFactorForm] = useState(false); + const [twoFactorValidationError, setTwoFactorValidationError] = useState(null); + + const { derivedRecipientAccessAuth, user } = useRequiredDocumentSigningAuthContext(); + const form = useForm({ resolver: allowDictateNextSigner ? zodResolver(ZNextSignerFormSchema) : undefined, defaultValues: { @@ -75,6 +92,11 @@ export const DocumentSigningCompleteDialog = ({ const isComplete = useMemo(() => !fieldsContainUnsignedRequiredField(fields), [fields]); + const completionRequires2FA = useMemo( + () => derivedRecipientAccessAuth.includes('TWO_FACTOR_AUTH'), + [derivedRecipientAccessAuth], + ); + const handleOpenChange = (open: boolean) => { if (form.formState.isSubmitting || !isComplete) { return; @@ -93,16 +115,43 @@ export const DocumentSigningCompleteDialog = ({ const onFormSubmit = async (data: TNextSignerFormSchema) => { try { - if (allowDictateNextSigner && data.name && data.email) { - await onSignatureComplete({ name: data.name, email: data.email }); - } else { - await onSignatureComplete(); + // Check if 2FA is required + if (completionRequires2FA && !data.accessAuthOptions) { + setShowTwoFactorForm(true); + return; } + + const nextSigner = + allowDictateNextSigner && data.name && data.email + ? { name: data.name, email: data.email } + : undefined; + + await onSignatureComplete(nextSigner, data.accessAuthOptions); } catch (error) { - console.error('Error completing signature:', error); + const err = AppError.parseError(error); + + if (AppErrorCode.TWO_FACTOR_AUTH_FAILED === err.code) { + // This was a 2FA validation failure - show the 2FA dialog again with error + form.setValue('accessAuthOptions', undefined); + + setTwoFactorValidationError('Invalid verification code. Please try again.'); + setShowTwoFactorForm(true); + + return; + } } }; + const onTwoFactorFormSubmit = (validatedAuthOptions: TRecipientAccessAuth) => { + form.setValue('accessAuthOptions', validatedAuthOptions); + + setShowTwoFactorForm(false); + setTwoFactorValidationError(null); + + // Now trigger the form submission with auth options + void form.handleSubmit(onFormSubmit)(); + }; + const isNextSignerValid = !allowDictateNextSigner || (form.watch('name') && form.watch('email')); return ( @@ -116,7 +165,7 @@ export const DocumentSigningCompleteDialog = ({ loading={isSubmitting} disabled={disabled} > - {match({ isComplete, role }) + {match({ isComplete, role: recipient.role }) .with({ isComplete: false }, () => Next field) .with({ isComplete: true, role: RecipientRole.APPROVER }, () => Approve) .with({ isComplete: true, role: RecipientRole.VIEWER }, () => ( @@ -128,184 +177,194 @@ export const DocumentSigningCompleteDialog = ({ -
- -
- -
- {match(role) - .with(RecipientRole.VIEWER, () => Complete Viewing) - .with(RecipientRole.SIGNER, () => Complete Signing) - .with(RecipientRole.APPROVER, () => Complete Approval) - .with(RecipientRole.CC, () => Complete Viewing) - .with(RecipientRole.ASSISTANT, () => Complete Assisting) - .exhaustive()} -
-
- -
- {match(role) - .with(RecipientRole.VIEWER, () => ( - - - - You are about to complete viewing " - - {documentTitle} - - ". - -
Are you sure? -
-
- )) - .with(RecipientRole.SIGNER, () => ( - - - - You are about to complete signing " - - {documentTitle} - - ". - -
Are you sure? -
-
- )) - .with(RecipientRole.APPROVER, () => ( - - - - You are about to complete approving{' '} - - "{documentTitle}" - - . - -
Are you sure? -
-
- )) - .otherwise(() => ( - - - - You are about to complete viewing " - - {documentTitle} - - ". - -
Are you sure? -
-
- ))} -
- - {allowDictateNextSigner && ( -
- {!isEditingNextSigner && ( -
-

- The next recipient to sign this document will be{' '} - {form.watch('name')} ( - {form.watch('email')}). -

- - -
- )} - - {isEditingNextSigner && ( -
- ( - - - Name - - - - - - - - )} - /> - - ( - - - Email - - - - - - - )} - /> -
- )} -
- )} - - - - -
- - - +
+ + +
+ {match(recipient.role) + .with(RecipientRole.VIEWER, () => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )) + .with(RecipientRole.SIGNER, () => ( + + + + You are about to complete signing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ )) + .with(RecipientRole.APPROVER, () => ( + + + + You are about to complete approving{' '} + + "{documentTitle}" + + . + +
Are you sure? +
+
+ )) + .otherwise(() => ( + + + + You are about to complete viewing " + + {documentTitle} + + ". + +
Are you sure? +
+
+ ))}
-
-
-
- + + {allowDictateNextSigner && ( +
+ {!isEditingNextSigner && ( +
+

+ The next recipient to sign this document will be{' '} + {form.watch('name')} ( + {form.watch('email')}). +

+ + +
+ )} + + {isEditingNextSigner && ( +
+ ( + + + Name + + + + + + + + )} + /> + + ( + + + Email + + + + + + + )} + /> +
+ )} +
+ )} + + + + +
+ + + +
+
+ + + + )} + + {showTwoFactorForm && ( + + )}
); diff --git a/apps/remix/app/components/general/document-signing/document-signing-form.tsx b/apps/remix/app/components/general/document-signing/document-signing-form.tsx index b2b58aa3b..dcc947645 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-form.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-form.tsx @@ -8,7 +8,7 @@ import { Controller, useForm } from 'react-hook-form'; import { useNavigate } from 'react-router'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; -import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; import { isFieldUnsignedAndRequired } from '@documenso/lib/utils/advanced-fields-helpers'; import { sortFieldsByPosition } from '@documenso/lib/utils/fields'; import type { RecipientWithFields } from '@documenso/prisma/types/recipient-with-fields'; @@ -34,10 +34,10 @@ export type DocumentSigningFormProps = { isRecipientsTurn: boolean; allRecipients?: RecipientWithFields[]; setSelectedSignerId?: (id: number | null) => void; - completeDocument: ( - authOptions?: TRecipientActionAuth, - nextSigner?: { email: string; name: string }, - ) => Promise; + completeDocument: (options: { + accessAuthOptions?: TRecipientAccessAuth; + nextSigner?: { email: string; name: string }; + }) => Promise; isSubmitting: boolean; fieldsValidated: () => void; nextRecipient?: RecipientWithFields; @@ -105,7 +105,7 @@ export const DocumentSigningForm = ({ setIsAssistantSubmitting(true); try { - await completeDocument(undefined, nextSigner); + await completeDocument({ nextSigner }); } catch (err) { toast({ title: 'Error', @@ -149,10 +149,10 @@ export const DocumentSigningForm = ({ documentTitle={document.title} fields={fields} fieldsValidated={localFieldsValidated} - onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); - }} - role={recipient.role} + onSignatureComplete={async (nextSigner, accessAuthOptions) => + completeDocument({ nextSigner, accessAuthOptions }) + } + recipient={recipient} allowDictateNextSigner={document.documentMeta?.allowDictateNextSigner} defaultNextSigner={ nextRecipient @@ -309,10 +309,13 @@ export const DocumentSigningForm = ({ fields={fields} fieldsValidated={localFieldsValidated} disabled={!isRecipientsTurn} - onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); - }} - role={recipient.role} + onSignatureComplete={async (nextSigner, accessAuthOptions) => + completeDocument({ + accessAuthOptions, + nextSigner, + }) + } + recipient={recipient} allowDictateNextSigner={ nextRecipient && document.documentMeta?.allowDictateNextSigner } diff --git a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx index a396973e7..6c06cdf44 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-page-view.tsx @@ -12,7 +12,7 @@ import { DEFAULT_DOCUMENT_DATE_FORMAT } from '@documenso/lib/constants/date-form import { PDF_VIEWER_PAGE_SELECTOR } from '@documenso/lib/constants/pdf-viewer'; import { DEFAULT_DOCUMENT_TIME_ZONE } from '@documenso/lib/constants/time-zones'; import type { DocumentAndSender } from '@documenso/lib/server-only/document/get-document-by-token'; -import type { TRecipientActionAuth } from '@documenso/lib/types/document-auth'; +import type { TRecipientAccessAuth } from '@documenso/lib/types/document-auth'; import { ZCheckboxFieldMeta, ZDropdownFieldMeta, @@ -46,6 +46,7 @@ import { DocumentSigningRejectDialog } from '~/components/general/document-signi import { DocumentSigningSignatureField } from '~/components/general/document-signing/document-signing-signature-field'; import { DocumentSigningTextField } from '~/components/general/document-signing/document-signing-text-field'; +import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider'; import { DocumentSigningCompleteDialog } from './document-signing-complete-dialog'; import { DocumentSigningRecipientProvider } from './document-signing-recipient-provider'; @@ -70,6 +71,12 @@ export const DocumentSigningPageView = ({ }: DocumentSigningPageViewProps) => { const { documentData, documentMeta } = document; + const { derivedRecipientAccessAuth, user: authUser } = useRequiredDocumentSigningAuthContext(); + + const hasAuthenticator = authUser?.twoFactorEnabled + ? authUser.twoFactorEnabled && authUser.email === recipient.email + : false; + const navigate = useNavigate(); const analytics = useAnalytics(); @@ -94,14 +101,16 @@ export const DocumentSigningPageView = ({ validateFieldsInserted(fieldsRequiringValidation); }; - const completeDocument = async ( - authOptions?: TRecipientActionAuth, - nextSigner?: { email: string; name: string }, - ) => { + const completeDocument = async (options: { + accessAuthOptions?: TRecipientAccessAuth; + nextSigner?: { email: string; name: string }; + }) => { + const { accessAuthOptions, nextSigner } = options; + const payload = { token: recipient.token, documentId: document.id, - authOptions, + accessAuthOptions, ...(nextSigner?.email && nextSigner?.name ? { nextSigner } : {}), }; @@ -265,10 +274,10 @@ export const DocumentSigningPageView = ({ fields={fields} fieldsValidated={fieldsValidated} disabled={!isRecipientsTurn} - onSignatureComplete={async (nextSigner) => { - await completeDocument(undefined, nextSigner); - }} - role={recipient.role} + onSignatureComplete={async (nextSigner) => + completeDocument({ nextSigner }) + } + recipient={recipient} allowDictateNextSigner={ nextRecipient && documentMeta?.allowDictateNextSigner } diff --git a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx index c415a0e13..ff14ccd32 100644 --- a/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx +++ b/apps/remix/app/routes/_internal+/[__htmltopdf]+/certificate.tsx @@ -151,6 +151,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps) authLevel = match(accessAuthMethod) .with('ACCOUNT', () => _(msg`Account Authentication`)) + .with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Authentication`)) .with(undefined, () => _(msg`Email`)) .exhaustive(); } diff --git a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx index ee37e5d0b..f7e495477 100644 --- a/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/d.$token+/_index.tsx @@ -47,10 +47,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { }); // Ensure typesafety when we add more options. - const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) - .with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user)) - .with(undefined, () => true) - .exhaustive(); + const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) => + match(auth) + .with(DocumentAccessAuth.ACCOUNT, () => Boolean(session.user)) + .with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) + .exhaustive(), + ); if (!isAccessAuthValid) { return superLoaderJson({ diff --git a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx index d9c8b9c4c..06d3018cc 100644 --- a/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx +++ b/apps/remix/app/routes/_recipient+/sign.$token+/_index.tsx @@ -3,12 +3,12 @@ import { DocumentSigningOrder, DocumentStatus, RecipientRole, SigningStatus } fr import { Clock8 } from 'lucide-react'; import { Link, redirect } from 'react-router'; import { getOptionalLoaderContext } from 'server/utils/get-loader-session'; +import { match } from 'ts-pattern'; import signingCelebration from '@documenso/assets/images/signing-celebration.png'; import { getOptionalSession } from '@documenso/auth/server/lib/utils/get-session'; import { useOptionalSession } from '@documenso/lib/client-only/providers/session'; import { getDocumentAndSenderByToken } from '@documenso/lib/server-only/document/get-document-by-token'; -import { isRecipientAuthorized } from '@documenso/lib/server-only/document/is-recipient-authorized'; import { viewedDocument } from '@documenso/lib/server-only/document/viewed-document'; import { getCompletedFieldsForToken } from '@documenso/lib/server-only/field/get-completed-fields-for-token'; import { getFieldsForToken } from '@documenso/lib/server-only/field/get-fields-for-token'; @@ -19,6 +19,7 @@ import { getRecipientSignatures } from '@documenso/lib/server-only/recipient/get import { getRecipientsForAssistant } from '@documenso/lib/server-only/recipient/get-recipients-for-assistant'; import { getTeamSettings } from '@documenso/lib/server-only/team/get-team-settings'; import { getUserByEmail } from '@documenso/lib/server-only/user/get-user-by-email'; +import { DocumentAccessAuth } from '@documenso/lib/types/document-auth'; import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; import { SigningCard3D } from '@documenso/ui/components/signing-card'; @@ -98,16 +99,16 @@ export async function loader({ params, request }: Route.LoaderArgs) { recipientAuth: recipient.authOptions, }); - const isDocumentAccessValid = await isRecipientAuthorized({ - type: 'ACCESS', - documentAuthOptions: document.authOptions, - recipient, - userId: user?.id, - }); + const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) => + match(accesssAuth) + .with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email) + .with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement + .exhaustive(), + ); let recipientHasAccount: boolean | null = null; - if (!isDocumentAccessValid) { + if (!isAccessAuthValid) { recipientHasAccount = await getUserByEmail({ email: recipient.email }) .then((user) => !!user) .catch(() => false); diff --git a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx index 5c3d1d916..4c61d9cea 100644 --- a/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/direct.$url.tsx @@ -58,10 +58,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { documentAuth: template.authOptions, }); - const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) - .with(DocumentAccessAuth.ACCOUNT, () => !!user) - .with(undefined, () => true) - .exhaustive(); + const isAccessAuthValid = derivedRecipientAccessAuth.every((auth) => + match(auth) + .with(DocumentAccessAuth.ACCOUNT, () => !!user) + .with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct links + .exhaustive(), + ); if (!isAccessAuthValid) { throw data( diff --git a/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx b/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx index 668ca417d..c4eb156b4 100644 --- a/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx +++ b/apps/remix/app/routes/embed+/_v0+/sign.$url.tsx @@ -75,10 +75,12 @@ export async function loader({ params, request }: Route.LoaderArgs) { documentAuth: document.authOptions, }); - const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) - .with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email) - .with(undefined, () => true) - .exhaustive(); + const isAccessAuthValid = derivedRecipientAccessAuth.every((accesssAuth) => + match(accesssAuth) + .with(DocumentAccessAuth.ACCOUNT, () => user && user.email === recipient.email) + .with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => true) // Allow without account requirement + .exhaustive(), + ); if (!isAccessAuthValid) { throw data( diff --git a/packages/email/template-components/template-access-auth-2fa.tsx b/packages/email/template-components/template-access-auth-2fa.tsx new file mode 100644 index 000000000..edaa4df7e --- /dev/null +++ b/packages/email/template-components/template-access-auth-2fa.tsx @@ -0,0 +1,60 @@ +import { Trans } from '@lingui/react/macro'; + +import { Heading, Img, Section, Text } from '../components'; + +export type TemplateAccessAuth2FAProps = { + documentTitle: string; + code: string; + userEmail: string; + userName: string; + expiresInMinutes: number; + assetBaseUrl?: string; +}; + +export const TemplateAccessAuth2FA = ({ + documentTitle, + code, + userName, + expiresInMinutes, + assetBaseUrl = 'http://localhost:3002', +}: TemplateAccessAuth2FAProps) => { + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( +
+ Document + +
+ + Verification Code Required + + + + + Hi {userName}, you need to enter a verification code to complete the document " + {documentTitle}". + + + +
+ + Your verification code: + + {code} +
+ + + This code will expire in {expiresInMinutes} minutes. + + + + + If you didn't request this verification code, you can safely ignore this email. + + +
+
+ ); +}; diff --git a/packages/email/templates/access-auth-2fa.tsx b/packages/email/templates/access-auth-2fa.tsx new file mode 100644 index 000000000..a635da3cb --- /dev/null +++ b/packages/email/templates/access-auth-2fa.tsx @@ -0,0 +1,77 @@ +import { msg } from '@lingui/core/macro'; +import { useLingui } from '@lingui/react'; + +import { Body, Container, Head, Html, Img, Preview, Section } from '../components'; +import { useBranding } from '../providers/branding'; +import { TemplateAccessAuth2FA } from '../template-components/template-access-auth-2fa'; +import { TemplateFooter } from '../template-components/template-footer'; + +export type AccessAuth2FAEmailTemplateProps = { + documentTitle: string; + code: string; + userEmail: string; + userName: string; + expiresInMinutes: number; + assetBaseUrl?: string; +}; + +export const AccessAuth2FAEmailTemplate = ({ + documentTitle, + code, + userEmail, + userName, + expiresInMinutes, + assetBaseUrl = 'http://localhost:3002', +}: AccessAuth2FAEmailTemplateProps) => { + const { _ } = useLingui(); + + const branding = useBranding(); + + const previewText = msg`Your verification code is ${code}`; + + const getAssetUrl = (path: string) => { + return new URL(path, assetBaseUrl).toString(); + }; + + return ( + + + {_(previewText)} + + +
+ +
+ {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + Documenso Logo + )} + + +
+
+ +
+ + + + +
+ + + ); +}; + +export default AccessAuth2FAEmailTemplate; diff --git a/packages/lib/errors/app-error.ts b/packages/lib/errors/app-error.ts index ad78e3417..ee82bcd91 100644 --- a/packages/lib/errors/app-error.ts +++ b/packages/lib/errors/app-error.ts @@ -17,6 +17,7 @@ export enum AppErrorCode { 'RETRY_EXCEPTION' = 'RETRY_EXCEPTION', 'SCHEMA_FAILED' = 'SCHEMA_FAILED', 'TOO_MANY_REQUESTS' = 'TOO_MANY_REQUESTS', + 'TWO_FACTOR_AUTH_FAILED' = 'TWO_FACTOR_AUTH_FAILED', } export const genericErrorCodeToTrpcErrorCodeMap: Record = @@ -32,6 +33,7 @@ export const genericErrorCodeToTrpcErrorCodeMap: Record { + if (!DOCUMENSO_ENCRYPTION_KEY) { + throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY'); + } + + const identity = `email-2fa|v1|email:${email}|id:${documentId}`; + + const secret = hmac(sha256, DOCUMENSO_ENCRYPTION_KEY, identity); + + const uri = createTOTPKeyURI(ISSUER, email, secret); + + return { + uri, + secret, + }; +}; diff --git a/packages/lib/server-only/2fa/email/generate-2fa-token-from-email.ts b/packages/lib/server-only/2fa/email/generate-2fa-token-from-email.ts new file mode 100644 index 000000000..9e5328291 --- /dev/null +++ b/packages/lib/server-only/2fa/email/generate-2fa-token-from-email.ts @@ -0,0 +1,23 @@ +import { generateHOTP } from 'oslo/otp'; + +import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email'; + +export type GenerateTwoFactorTokenFromEmailOptions = { + documentId: number; + email: string; + period?: number; +}; + +export const generateTwoFactorTokenFromEmail = async ({ + email, + documentId, + period = 30_000, +}: GenerateTwoFactorTokenFromEmailOptions) => { + const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId }); + + const counter = Math.floor(Date.now() / period); + + const token = await generateHOTP(secret, counter); + + return token; +}; diff --git a/packages/lib/server-only/2fa/email/send-2fa-token-email.ts b/packages/lib/server-only/2fa/email/send-2fa-token-email.ts new file mode 100644 index 000000000..64e52ab5c --- /dev/null +++ b/packages/lib/server-only/2fa/email/send-2fa-token-email.ts @@ -0,0 +1,124 @@ +import { createElement } from 'react'; + +import { msg } from '@lingui/core/macro'; + +import { mailer } from '@documenso/email/mailer'; +import { AccessAuth2FAEmailTemplate } from '@documenso/email/templates/access-auth-2fa'; +import { prisma } from '@documenso/prisma'; + +import { getI18nInstance } from '../../../client-only/providers/i18n-server'; +import { NEXT_PUBLIC_WEBAPP_URL } from '../../../constants/app'; +import { AppError, AppErrorCode } from '../../../errors/app-error'; +import { DOCUMENT_AUDIT_LOG_TYPE } from '../../../types/document-audit-logs'; +import { createDocumentAuditLogData } from '../../../utils/document-audit-logs'; +import { renderEmailWithI18N } from '../../../utils/render-email-with-i18n'; +import { getEmailContext } from '../../email/get-email-context'; +import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from './constants'; +import { generateTwoFactorTokenFromEmail } from './generate-2fa-token-from-email'; + +export type Send2FATokenEmailOptions = { + token: string; + documentId: number; +}; + +export const send2FATokenEmail = async ({ token, documentId }: Send2FATokenEmailOptions) => { + const document = await prisma.document.findFirst({ + where: { + id: documentId, + recipients: { + some: { + token, + }, + }, + }, + include: { + recipients: { + where: { + token, + }, + }, + documentMeta: true, + team: { + select: { + teamEmail: true, + name: true, + }, + }, + }, + }); + + if (!document) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document not found', + }); + } + + const [recipient] = document.recipients; + + if (!recipient) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Recipient not found', + }); + } + + const twoFactorTokenToken = await generateTwoFactorTokenFromEmail({ + documentId, + email: recipient.email, + }); + + const { branding, emailLanguage, senderEmail, replyToEmail } = await getEmailContext({ + emailType: 'RECIPIENT', + source: { + type: 'team', + teamId: document.teamId, + }, + meta: document.documentMeta, + }); + + const i18n = await getI18nInstance(emailLanguage); + + const subject = i18n._(msg`Your two-factor authentication code`); + + const template = createElement(AccessAuth2FAEmailTemplate, { + documentTitle: document.title, + userName: recipient.name, + userEmail: recipient.email, + code: twoFactorTokenToken, + expiresInMinutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES, + assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(), + }); + + const [html, text] = await Promise.all([ + renderEmailWithI18N(template, { lang: emailLanguage, branding }), + renderEmailWithI18N(template, { lang: emailLanguage, branding, plainText: true }), + ]); + + await prisma.$transaction( + async (tx) => { + await mailer.sendMail({ + to: { + address: recipient.email, + name: recipient.name, + }, + from: senderEmail, + replyTo: replyToEmail, + subject, + html, + text, + }); + + await tx.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED, + documentId: document.id, + data: { + recipientEmail: recipient.email, + recipientName: recipient.name, + recipientId: recipient.id, + }, + }), + }); + }, + { timeout: 30_000 }, + ); +}; diff --git a/packages/lib/server-only/2fa/email/validate-2fa-token-from-email.ts b/packages/lib/server-only/2fa/email/validate-2fa-token-from-email.ts new file mode 100644 index 000000000..c278812ea --- /dev/null +++ b/packages/lib/server-only/2fa/email/validate-2fa-token-from-email.ts @@ -0,0 +1,37 @@ +import { generateHOTP } from 'oslo/otp'; + +import { generateTwoFactorCredentialsFromEmail } from './generate-2fa-credentials-from-email'; + +export type ValidateTwoFactorTokenFromEmailOptions = { + documentId: number; + email: string; + code: string; + period?: number; + window?: number; +}; + +export const validateTwoFactorTokenFromEmail = async ({ + documentId, + email, + code, + period = 30_000, + window = 1, +}: ValidateTwoFactorTokenFromEmailOptions) => { + const { secret } = generateTwoFactorCredentialsFromEmail({ email, documentId }); + + let now = Date.now(); + + for (let i = 0; i < window; i++) { + const counter = Math.floor(now / period); + + const hotp = await generateHOTP(secret, counter); + + if (code === hotp) { + return true; + } + + now -= period; + } + + return false; +}; diff --git a/packages/lib/server-only/document/complete-document-with-token.ts b/packages/lib/server-only/document/complete-document-with-token.ts index 92d304c2f..b143b2645 100644 --- a/packages/lib/server-only/document/complete-document-with-token.ts +++ b/packages/lib/server-only/document/complete-document-with-token.ts @@ -18,7 +18,8 @@ import { prisma } from '@documenso/prisma'; import { AppError, AppErrorCode } from '../../errors/app-error'; import { jobs } from '../../jobs/client'; -import type { TRecipientActionAuth } from '../../types/document-auth'; +import type { TRecipientAccessAuth, TRecipientActionAuth } from '../../types/document-auth'; +import { DocumentAuth } from '../../types/document-auth'; import { ZWebhookDocumentSchema, mapDocumentToWebhookDocumentPayload, @@ -26,6 +27,7 @@ import { import { extractDocumentAuthMethods } from '../../utils/document-auth'; import { getIsRecipientsTurnToSign } from '../recipient/get-is-recipient-turn'; import { triggerWebhook } from '../webhooks/trigger/trigger-webhook'; +import { isRecipientAuthorized } from './is-recipient-authorized'; import { sendPendingEmail } from './send-pending-email'; export type CompleteDocumentWithTokenOptions = { @@ -33,6 +35,7 @@ export type CompleteDocumentWithTokenOptions = { documentId: number; userId?: number; authOptions?: TRecipientActionAuth; + accessAuthOptions?: TRecipientAccessAuth; requestMetadata?: RequestMetadata; nextSigner?: { email: string; @@ -64,6 +67,8 @@ const getDocument = async ({ token, documentId }: CompleteDocumentWithTokenOptio export const completeDocumentWithToken = async ({ token, documentId, + userId, + accessAuthOptions, requestMetadata, nextSigner, }: CompleteDocumentWithTokenOptions) => { @@ -111,24 +116,57 @@ export const completeDocumentWithToken = async ({ throw new Error(`Recipient ${recipient.id} has unsigned fields`); } - // Document reauth for completing documents is currently not required. + // Check ACCESS AUTH 2FA validation during document completion + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); - // const { derivedRecipientActionAuth } = extractDocumentAuthMethods({ - // documentAuth: document.authOptions, - // recipientAuth: recipient.authOptions, - // }); + if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) { + if (!accessAuthOptions) { + throw new AppError(AppErrorCode.UNAUTHORIZED, { + message: 'Access authentication required', + }); + } - // const isValid = await isRecipientAuthorized({ - // type: 'ACTION', - // document: document, - // recipient: recipient, - // userId, - // authOptions, - // }); + const isValid = await isRecipientAuthorized({ + type: 'ACCESS_2FA', + documentAuthOptions: document.authOptions, + recipient: recipient, + userId, // Can be undefined for non-account recipients + authOptions: accessAuthOptions, + }); - // if (!isValid) { - // throw new AppError(AppErrorCode.UNAUTHORIZED, 'Invalid authentication values'); - // } + if (!isValid) { + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED, + documentId: document.id, + data: { + recipientId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + }, + }), + }); + + throw new AppError(AppErrorCode.TWO_FACTOR_AUTH_FAILED, { + message: 'Invalid 2FA authentication', + }); + } + + await prisma.documentAuditLog.create({ + data: createDocumentAuditLogData({ + type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED, + documentId: document.id, + data: { + recipientId: recipient.id, + recipientName: recipient.name, + recipientEmail: recipient.email, + }, + }), + }); + } await prisma.$transaction(async (tx) => { await tx.recipient.update({ diff --git a/packages/lib/server-only/document/is-recipient-authorized.ts b/packages/lib/server-only/document/is-recipient-authorized.ts index 873b087e1..c8d54d2a4 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 { validateTwoFactorTokenFromEmail } from '../2fa/email/validate-2fa-token-from-email'; import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token'; import { verifyPassword } from '../2fa/verify-password'; import { AppError, AppErrorCode } from '../../errors/app-error'; @@ -14,9 +15,10 @@ import { getAuthenticatorOptions } from '../../utils/authenticator'; import { extractDocumentAuthMethods } from '../../utils/document-auth'; type IsRecipientAuthorizedOptions = { - type: 'ACCESS' | 'ACTION'; + // !: Probably find a better name than 'ACCESS_2FA' if requirements change. + type: 'ACCESS' | 'ACCESS_2FA' | 'ACTION'; documentAuthOptions: Document['authOptions']; - recipient: Pick; + recipient: Pick; /** * The ID of the user who initiated the request. @@ -61,8 +63,11 @@ export const isRecipientAuthorized = async ({ recipientAuth: recipient.authOptions, }); - const authMethods: TDocumentAuth[] = - type === 'ACCESS' ? derivedRecipientAccessAuth : derivedRecipientActionAuth; + const authMethods: TDocumentAuth[] = match(type) + .with('ACCESS', () => derivedRecipientAccessAuth) + .with('ACCESS_2FA', () => derivedRecipientAccessAuth) + .with('ACTION', () => derivedRecipientActionAuth) + .exhaustive(); // Early true return when auth is not required. if ( @@ -72,6 +77,11 @@ export const isRecipientAuthorized = async ({ return true; } + // Early true return for ACCESS auth if all methods are 2FA since validation happens in ACCESS_2FA. + if (type === 'ACCESS' && authMethods.every((method) => method === DocumentAuth.TWO_FACTOR_AUTH)) { + return true; + } + // Create auth options when none are passed for account. if (!authOptions && authMethods.some((method) => method === DocumentAuth.ACCOUNT)) { authOptions = { @@ -80,12 +90,16 @@ export const isRecipientAuthorized = async ({ } // Authentication required does not match provided method. - if (!authOptions || !authMethods.includes(authOptions.type) || !userId) { + if (!authOptions || !authMethods.includes(authOptions.type)) { return false; } return await match(authOptions) .with({ type: DocumentAuth.ACCOUNT }, async () => { + if (!userId) { + return false; + } + const recipientUser = await getUserByEmail(recipient.email); if (!recipientUser) { @@ -95,13 +109,40 @@ export const isRecipientAuthorized = async ({ return recipientUser.id === userId; }) .with({ type: DocumentAuth.PASSKEY }, async ({ authenticationResponse, tokenReference }) => { + if (!userId) { + return false; + } + return await isPasskeyAuthValid({ userId, authenticationResponse, tokenReference, }); }) - .with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => { + .with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token, method }) => { + if (type === 'ACCESS') { + return true; + } + + if (type === 'ACCESS_2FA' && method === 'email') { + if (!recipient.documentId) { + throw new AppError(AppErrorCode.NOT_FOUND, { + message: 'Document ID is required for email 2FA verification', + }); + } + + return await validateTwoFactorTokenFromEmail({ + documentId: recipient.documentId, + email: recipient.email, + code: token, + window: 10, // 5 minutes worth of tokens + }); + } + + if (!userId) { + return false; + } + const user = await prisma.user.findFirst({ where: { id: userId, @@ -115,6 +156,7 @@ export const isRecipientAuthorized = async ({ }); } + // For ACTION auth or authenticator method, use TOTP return await verifyTwoFactorAuthenticationToken({ user, totpCode: token, @@ -122,6 +164,10 @@ export const isRecipientAuthorized = async ({ }); }) .with({ type: DocumentAuth.PASSWORD }, async ({ password }) => { + if (!userId) { + return false; + } + return await verifyPassword({ userId, password, diff --git a/packages/lib/server-only/document/validate-field-auth.ts b/packages/lib/server-only/document/validate-field-auth.ts index 8dfc0ab48..cbb9bf55f 100644 --- a/packages/lib/server-only/document/validate-field-auth.ts +++ b/packages/lib/server-only/document/validate-field-auth.ts @@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized'; export type ValidateFieldAuthOptions = { documentAuthOptions: Document['authOptions']; - recipient: Pick; + recipient: Pick; field: Field; userId?: number; authOptions?: TRecipientActionAuth; diff --git a/packages/lib/server-only/template/create-document-from-direct-template.ts b/packages/lib/server-only/template/create-document-from-direct-template.ts index a7afd8a1a..b223dd49f 100644 --- a/packages/lib/server-only/template/create-document-from-direct-template.ts +++ b/packages/lib/server-only/template/create-document-from-direct-template.ts @@ -159,6 +159,7 @@ export const createDocumentFromDirectTemplate = async ({ // Ensure typesafety when we add more options. const isAccessAuthValid = match(derivedRecipientAccessAuth.at(0)) .with(DocumentAccessAuth.ACCOUNT, () => user && user?.email === directRecipientEmail) + .with(DocumentAccessAuth.TWO_FACTOR_AUTH, () => false) // Not supported for direct templates .with(undefined, () => true) .exhaustive(); @@ -205,6 +206,7 @@ export const createDocumentFromDirectTemplate = async ({ recipient: { authOptions: directTemplateRecipient.authOptions, email: directRecipientEmail, + documentId: template.id, }, field: templateField, userId: user?.id, diff --git a/packages/lib/types/document-audit-logs.ts b/packages/lib/types/document-audit-logs.ts index e11f9b31b..c604686de 100644 --- a/packages/lib/types/document-audit-logs.ts +++ b/packages/lib/types/document-audit-logs.ts @@ -40,6 +40,11 @@ export const ZDocumentAuditLogTypeSchema = z.enum([ 'DOCUMENT_TITLE_UPDATED', // When the document title is updated. 'DOCUMENT_EXTERNAL_ID_UPDATED', // When the document external ID is updated. 'DOCUMENT_MOVED_TO_TEAM', // When the document is moved to a team. + + // ACCESS AUTH 2FA events. + 'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested. + 'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated. + 'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails. ]); export const ZDocumentAuditLogEmailTypeSchema = z.enum([ @@ -487,6 +492,42 @@ export const ZDocumentAuditLogEventDocumentRecipientRejectedSchema = z.object({ }), }); +/** + * Event: Document recipient requested a 2FA token. + */ +export const ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED), + data: z.object({ + recipientEmail: z.string(), + recipientName: z.string(), + recipientId: z.number(), + }), +}); + +/** + * Event: Document recipient validated a 2FA token. + */ +export const ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED), + data: z.object({ + recipientEmail: z.string(), + recipientName: z.string(), + recipientId: z.number(), + }), +}); + +/** + * Event: Document recipient failed to validate a 2FA token. + */ +export const ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema = z.object({ + type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED), + data: z.object({ + recipientEmail: z.string(), + recipientName: z.string(), + recipientId: z.number(), + }), +}); + /** * Event: Document sent. */ @@ -627,6 +668,9 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and( ZDocumentAuditLogEventDocumentViewedSchema, ZDocumentAuditLogEventDocumentRecipientCompleteSchema, ZDocumentAuditLogEventDocumentRecipientRejectedSchema, + ZDocumentAuditLogEventDocumentRecipientRequested2FAEmailSchema, + ZDocumentAuditLogEventDocumentRecipientValidated2FAEmailSchema, + ZDocumentAuditLogEventDocumentRecipientFailed2FAEmailSchema, ZDocumentAuditLogEventDocumentSentSchema, ZDocumentAuditLogEventDocumentTitleUpdatedSchema, ZDocumentAuditLogEventDocumentExternalIdUpdatedSchema, diff --git a/packages/lib/types/document-auth.ts b/packages/lib/types/document-auth.ts index 493e14374..af28da615 100644 --- a/packages/lib/types/document-auth.ts +++ b/packages/lib/types/document-auth.ts @@ -37,6 +37,7 @@ const ZDocumentAuthPasswordSchema = z.object({ const ZDocumentAuth2FASchema = z.object({ type: z.literal(DocumentAuth.TWO_FACTOR_AUTH), token: z.string().min(4).max(10), + method: z.enum(['email', 'authenticator']).default('authenticator').optional(), }); /** @@ -55,9 +56,12 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [ * * Must keep these two in sync. */ -export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ZDocumentAuthAccountSchema]); +export const ZDocumentAccessAuthSchema = z.discriminatedUnion('type', [ + ZDocumentAuthAccountSchema, + ZDocumentAuth2FASchema, +]); export const ZDocumentAccessAuthTypesSchema = z - .enum([DocumentAuth.ACCOUNT]) + .enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH]) .describe('The type of authentication required for the recipient to access the document.'); /** @@ -89,9 +93,10 @@ export const ZDocumentActionAuthTypesSchema = z */ export const ZRecipientAccessAuthSchema = z.discriminatedUnion('type', [ ZDocumentAuthAccountSchema, + ZDocumentAuth2FASchema, ]); export const ZRecipientAccessAuthTypesSchema = z - .enum([DocumentAuth.ACCOUNT]) + .enum([DocumentAuth.ACCOUNT, DocumentAuth.TWO_FACTOR_AUTH]) .describe('The type of authentication required for the recipient to access the document.'); /** diff --git a/packages/lib/utils/document-audit-logs.ts b/packages/lib/utils/document-audit-logs.ts index fe4c43e1d..f5a14478e 100644 --- a/packages/lib/utils/document-audit-logs.ts +++ b/packages/lib/utils/document-audit-logs.ts @@ -476,6 +476,36 @@ export const formatDocumentAuditLogAction = ( identified: result, }; }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_REQUESTED }, ({ data }) => { + const userName = prefix || _(msg`Recipient`); + + const result = msg`${userName} requested a 2FA token for the document`; + + return { + anonymous: result, + identified: result, + }; + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_VALIDATED }, ({ data }) => { + const userName = prefix || _(msg`Recipient`); + + const result = msg`${userName} validated a 2FA token for the document`; + + return { + anonymous: result, + identified: result, + }; + }) + .with({ type: DOCUMENT_AUDIT_LOG_TYPE.DOCUMENT_ACCESS_AUTH_2FA_FAILED }, ({ data }) => { + const userName = prefix || _(msg`Recipient`); + + const result = msg`${userName} failed to validate a 2FA token for the document`; + + return { + anonymous: result, + identified: result, + }; + }) .with({ type: DOCUMENT_AUDIT_LOG_TYPE.EMAIL_SENT }, ({ data }) => ({ anonymous: data.isResending ? msg`Email resent` : msg`Email sent`, identified: data.isResending diff --git a/packages/trpc/server/document-router/access-auth-request-2fa-email.ts b/packages/trpc/server/document-router/access-auth-request-2fa-email.ts new file mode 100644 index 000000000..849636a0f --- /dev/null +++ b/packages/trpc/server/document-router/access-auth-request-2fa-email.ts @@ -0,0 +1,94 @@ +import { TRPCError } from '@trpc/server'; +import { DateTime } from 'luxon'; + +import { TWO_FACTOR_EMAIL_EXPIRATION_MINUTES } from '@documenso/lib/server-only/2fa/email/constants'; +import { send2FATokenEmail } from '@documenso/lib/server-only/2fa/email/send-2fa-token-email'; +import { DocumentAuth } from '@documenso/lib/types/document-auth'; +import { extractDocumentAuthMethods } from '@documenso/lib/utils/document-auth'; +import { prisma } from '@documenso/prisma'; + +import { procedure } from '../trpc'; +import { + ZAccessAuthRequest2FAEmailRequestSchema, + ZAccessAuthRequest2FAEmailResponseSchema, +} from './access-auth-request-2fa-email.types'; + +export const accessAuthRequest2FAEmailRoute = procedure + .input(ZAccessAuthRequest2FAEmailRequestSchema) + .output(ZAccessAuthRequest2FAEmailResponseSchema) + .mutation(async ({ input, ctx }) => { + try { + const { token } = input; + + const user = ctx.user; + + // Get document and recipient by token + const document = await prisma.document.findFirst({ + where: { + recipients: { + some: { + token, + }, + }, + }, + include: { + recipients: { + where: { + token, + }, + }, + }, + }); + + if (!document) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Document not found', + }); + } + + const [recipient] = document.recipients; + + const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({ + documentAuth: document.authOptions, + recipientAuth: recipient.authOptions, + }); + + if (!derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: '2FA is not required for this document', + }); + } + + // if (user && recipient.email !== user.email) { + // throw new TRPCError({ + // code: 'UNAUTHORIZED', + // message: 'User does not match recipient', + // }); + // } + + const expiresAt = DateTime.now().plus({ minutes: TWO_FACTOR_EMAIL_EXPIRATION_MINUTES }); + + await send2FATokenEmail({ + token, + documentId: document.id, + }); + + return { + success: true, + expiresAt: expiresAt.toJSDate(), + }; + } catch (error) { + console.error('Error sending access auth 2FA email:', error); + + if (error instanceof TRPCError) { + throw error; + } + + throw new TRPCError({ + code: 'INTERNAL_SERVER_ERROR', + message: 'Failed to send 2FA email', + }); + } + }); diff --git a/packages/trpc/server/document-router/access-auth-request-2fa-email.types.ts b/packages/trpc/server/document-router/access-auth-request-2fa-email.types.ts new file mode 100644 index 000000000..a60e8b991 --- /dev/null +++ b/packages/trpc/server/document-router/access-auth-request-2fa-email.types.ts @@ -0,0 +1,17 @@ +import { z } from 'zod'; + +export const ZAccessAuthRequest2FAEmailRequestSchema = z.object({ + token: z.string().min(1), +}); + +export const ZAccessAuthRequest2FAEmailResponseSchema = z.object({ + success: z.boolean(), + expiresAt: z.date(), +}); + +export type TAccessAuthRequest2FAEmailRequest = z.infer< + typeof ZAccessAuthRequest2FAEmailRequestSchema +>; +export type TAccessAuthRequest2FAEmailResponse = z.infer< + typeof ZAccessAuthRequest2FAEmailResponseSchema +>; diff --git a/packages/trpc/server/document-router/router.ts b/packages/trpc/server/document-router/router.ts index da5b8e769..8e2e453bd 100644 --- a/packages/trpc/server/document-router/router.ts +++ b/packages/trpc/server/document-router/router.ts @@ -1,4 +1,5 @@ import { router } from '../trpc'; +import { accessAuthRequest2FAEmailRoute } from './access-auth-request-2fa-email'; import { createDocumentRoute } from './create-document'; import { createDocumentTemporaryRoute } from './create-document-temporary'; import { deleteDocumentRoute } from './delete-document'; @@ -38,6 +39,10 @@ export const documentRouter = router({ getDocumentByToken: getDocumentByTokenRoute, findDocumentsInternal: findDocumentsInternalRoute, + accessAuth: router({ + request2FAEmail: accessAuthRequest2FAEmailRoute, + }), + auditLog: { find: findDocumentAuditLogsRoute, download: downloadDocumentAuditLogsRoute, diff --git a/packages/trpc/server/recipient-router/router.ts b/packages/trpc/server/recipient-router/router.ts index 876e21942..401251b27 100644 --- a/packages/trpc/server/recipient-router/router.ts +++ b/packages/trpc/server/recipient-router/router.ts @@ -525,7 +525,7 @@ export const recipientRouter = router({ completeDocumentWithToken: procedure .input(ZCompleteDocumentWithTokenMutationSchema) .mutation(async ({ input, ctx }) => { - const { token, documentId, authOptions, nextSigner } = input; + const { token, documentId, authOptions, accessAuthOptions, nextSigner } = input; ctx.logger.info({ input: { @@ -537,6 +537,7 @@ export const recipientRouter = router({ token, documentId, authOptions, + accessAuthOptions, nextSigner, userId: ctx.user?.id, requestMetadata: ctx.metadata.requestMetadata, diff --git a/packages/trpc/server/recipient-router/schema.ts b/packages/trpc/server/recipient-router/schema.ts index 2aa0f15c0..2307cd6cb 100644 --- a/packages/trpc/server/recipient-router/schema.ts +++ b/packages/trpc/server/recipient-router/schema.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { isTemplateRecipientEmailPlaceholder } from '@documenso/lib/constants/template'; import { + ZRecipientAccessAuthSchema, ZRecipientAccessAuthTypesSchema, ZRecipientActionAuthSchema, ZRecipientActionAuthTypesSchema, @@ -164,6 +165,7 @@ export const ZCompleteDocumentWithTokenMutationSchema = z.object({ token: z.string(), documentId: z.number(), authOptions: ZRecipientActionAuthSchema.optional(), + accessAuthOptions: ZRecipientAccessAuthSchema.optional(), nextSigner: z .object({ email: z.string().email().max(254),