feat: email verification for document signing 2FA

This commit is contained in:
Ephraim Atta-Duncan
2025-04-30 21:55:08 +00:00
parent 7a3763bb66
commit d48705024e
8 changed files with 524 additions and 68 deletions

View File

@ -8,6 +8,7 @@ import { z } from 'zod';
import { AppError } from '@documenso/lib/errors/app-error'; import { AppError } from '@documenso/lib/errors/app-error';
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth'; import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
import { trpc } from '@documenso/trpc/react';
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
import { Button } from '@documenso/ui/primitives/button'; import { Button } from '@documenso/ui/primitives/button';
import { DialogFooter } from '@documenso/ui/primitives/dialog'; import { DialogFooter } from '@documenso/ui/primitives/dialog';
@ -20,6 +21,8 @@ import {
FormMessage, FormMessage,
} from '@documenso/ui/primitives/form/form'; } from '@documenso/ui/primitives/form/form';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input'; import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { Tabs, TabsList, TabsTrigger } from '@documenso/ui/primitives/tabs';
import { useToast } from '@documenso/ui/primitives/use-toast';
import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog';
@ -51,6 +54,7 @@ export const DocumentSigningAuth2FA = ({
}: DocumentSigningAuth2FAProps) => { }: DocumentSigningAuth2FAProps) => {
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
useRequiredDocumentSigningAuthContext(); useRequiredDocumentSigningAuthContext();
const { toast } = useToast();
const form = useForm<T2FAAuthFormSchema>({ const form = useForm<T2FAAuthFormSchema>({
resolver: zodResolver(Z2FAAuthFormSchema), resolver: zodResolver(Z2FAAuthFormSchema),
@ -60,27 +64,71 @@ export const DocumentSigningAuth2FA = ({
}); });
const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false);
const [isEmailCodeSent, setIsEmailCodeSent] = useState(false);
const [isEmailCodeSending, setIsEmailCodeSending] = useState(false);
const [formErrorCode, setFormErrorCode] = useState<string | null>(null); const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
const [verificationMethod, setVerificationMethod] = useState<'app' | 'email'>(
user?.twoFactorEnabled ? 'app' : 'email',
);
const sendVerificationMutation = trpc.auth.sendEmailVerification.useMutation({
onSuccess: () => {
setIsEmailCodeSent(true);
toast({
title: 'Verification code sent',
description: `A verification code has been sent to ${recipient.email}`,
});
},
onError: (error) => {
console.error('Failed to send verification code', error);
toast({
title: 'Failed to send verification code',
description: 'Please try again or contact support',
variant: 'destructive',
});
},
onSettled: () => {
setIsEmailCodeSending(false);
},
});
const verifyCodeMutation = trpc.auth.verifyEmailCode.useMutation();
const sendEmailVerificationCode = async () => {
try {
setIsEmailCodeSending(true);
await sendVerificationMutation.mutateAsync({
recipientId: recipient.id,
});
} catch (error) {
// Error is handled in the mutation callbacks
}
};
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => { const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
try { try {
setIsCurrentlyAuthenticating(true); setIsCurrentlyAuthenticating(true);
if (verificationMethod === 'email') {
// Verify the email code first
await verifyCodeMutation.mutateAsync({
code: token,
recipientId: recipient.id,
});
}
await onReauthFormSubmit({ await onReauthFormSubmit({
type: DocumentAuth.TWO_FACTOR_AUTH, type: DocumentAuth.TWO_FACTOR_AUTH,
token, token,
}); });
setIsCurrentlyAuthenticating(false); setIsCurrentlyAuthenticating(false);
onOpenChange(false); onOpenChange(false);
} catch (err) { } catch (err) {
setIsCurrentlyAuthenticating(false); setIsCurrentlyAuthenticating(false);
const error = AppError.parseError(err); const error = AppError.parseError(err);
setFormErrorCode(error.code); setFormErrorCode(error.code);
// Todo: Alert.
} }
}; };
@ -91,20 +139,40 @@ export const DocumentSigningAuth2FA = ({
setIs2FASetupSuccessful(false); setIs2FASetupSuccessful(false);
setFormErrorCode(null); setFormErrorCode(null);
setIsEmailCodeSent(false);
if (open && !user?.twoFactorEnabled) {
setVerificationMethod('email');
}
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [open]); }, [open, user?.twoFactorEnabled]);
if (!user?.twoFactorEnabled && !is2FASetupSuccessful) { useEffect(() => {
if (open && verificationMethod === 'email' && !isEmailCodeSent && !isEmailCodeSending) {
void sendEmailVerificationCode();
}
}, [open, verificationMethod, isEmailCodeSent, isEmailCodeSending]);
if (verificationMethod === 'app' && !user?.twoFactorEnabled && !is2FASetupSuccessful) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<Tabs
value={verificationMethod}
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="app">Authenticator App</TabsTrigger>
<TabsTrigger value="email">Email Verification</TabsTrigger>
</TabsList>
</Tabs>
<Alert variant="warning"> <Alert variant="warning">
<AlertDescription> <AlertDescription>
<p> <p>
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? ( {recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? (
<Trans>You need to setup 2FA to mark this document as viewed.</Trans> <Trans>You need to setup 2FA to mark this document as viewed.</Trans>
) : ( ) : (
// Todo: Translate
`You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.` `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`
)} )}
</p> </p>
@ -129,6 +197,40 @@ export const DocumentSigningAuth2FA = ({
} }
return ( return (
<div className="space-y-4">
{user?.twoFactorEnabled && (
<Tabs
value={verificationMethod}
onValueChange={(val) => setVerificationMethod(val as 'app' | 'email')}
>
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="app">Authenticator App</TabsTrigger>
<TabsTrigger value="email">Email Verification</TabsTrigger>
</TabsList>
</Tabs>
)}
{verificationMethod === 'email' && (
<Alert variant="secondary">
<AlertDescription>
{isEmailCodeSent ? (
<p>
<Trans>
A verification code has been sent to {recipient.email}. Please enter it below to
continue.
</Trans>
</p>
) : (
<p>
<Trans>
We'll send a verification code to {recipient.email} to verify your identity.
</Trans>
</p>
)}
</AlertDescription>
</Alert>
)}
<Form {...form}> <Form {...form}>
<form onSubmit={form.handleSubmit(onFormSubmit)}> <form onSubmit={form.handleSubmit(onFormSubmit)}>
<fieldset disabled={isCurrentlyAuthenticating}> <fieldset disabled={isCurrentlyAuthenticating}>
@ -138,7 +240,13 @@ export const DocumentSigningAuth2FA = ({
name="token" name="token"
render={({ field }) => ( render={({ field }) => (
<FormItem> <FormItem>
<FormLabel required>2FA token</FormLabel> <FormLabel required>
{verificationMethod === 'app' ? (
<Trans>2FA token</Trans>
) : (
<Trans>Verification code</Trans>
)}
</FormLabel>
<FormControl> <FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}> <PinInput {...field} value={field.value ?? ''} maxLength={6}>
@ -157,6 +265,19 @@ export const DocumentSigningAuth2FA = ({
)} )}
/> />
{verificationMethod === 'email' && (
<div className="flex justify-center">
<Button
type="button"
variant="link"
disabled={isEmailCodeSending}
onClick={() => void sendEmailVerificationCode()}
>
{isEmailCodeSending ? <Trans>Sending...</Trans> : <Trans>Resend code</Trans>}
</Button>
</div>
)}
{formErrorCode && ( {formErrorCode && (
<Alert variant="destructive"> <Alert variant="destructive">
<AlertTitle> <AlertTitle>
@ -183,5 +304,6 @@ export const DocumentSigningAuth2FA = ({
</fieldset> </fieldset>
</form> </form>
</Form> </Form>
</div>
); );
}; };

View File

@ -0,0 +1,43 @@
import { Trans } from '@lingui/react/macro';
import { Section, Text } from '../components';
import { TemplateDocumentImage } from './template-document-image';
export type TemplateVerificationCodeProps = {
verificationCode: string;
assetBaseUrl: string;
};
export const TemplateVerificationCode = ({
verificationCode,
assetBaseUrl,
}: TemplateVerificationCodeProps) => {
return (
<>
<TemplateDocumentImage className="mt-6" assetBaseUrl={assetBaseUrl} />
<Section className="flex-row items-center justify-center">
<Text className="text-primary mx-auto mb-0 max-w-[80%] text-center text-lg font-semibold">
<Trans>Your verification code</Trans>
</Text>
<Text className="my-1 text-center text-base text-slate-400">
<Trans>Please use the code below to verify your identity for document signing.</Trans>
</Text>
<Text className="my-6 text-center text-3xl font-bold tracking-widest">
{verificationCode}
</Text>
<Text className="my-1 text-center text-sm text-slate-400">
<Trans>
If you did not request this code, you can ignore this email. The code will expire after
10 minutes.
</Trans>
</Text>
</Section>
</>
);
};
export default TemplateVerificationCode;

View File

@ -0,0 +1,62 @@
import { msg } from '@lingui/core/macro';
import { useLingui } from '@lingui/react';
import { Body, Container, Head, Hr, Html, Img, Preview, Section } from '../components';
import { useBranding } from '../providers/branding';
import { TemplateFooter } from '../template-components/template-footer';
import type { TemplateVerificationCodeProps } from '../template-components/template-verification-code';
import { TemplateVerificationCode } from '../template-components/template-verification-code';
export type VerificationCodeTemplateProps = Partial<TemplateVerificationCodeProps>;
export const VerificationCodeTemplate = ({
verificationCode = '000000',
assetBaseUrl = 'http://localhost:3002',
}: VerificationCodeTemplateProps) => {
const { _ } = useLingui();
const branding = useBranding();
const previewText = msg`Your verification code for document signing`;
const getAssetUrl = (path: string) => {
return new URL(path, assetBaseUrl).toString();
};
return (
<Html>
<Head />
<Preview>{_(previewText)}</Preview>
<Body className="mx-auto my-auto font-sans">
<Section className="bg-white">
<Container className="mx-auto mb-2 mt-8 max-w-xl rounded-lg border border-solid border-slate-200 p-2 backdrop-blur-sm">
<Section className="p-2">
{branding.brandingEnabled && branding.brandingLogo ? (
<Img src={branding.brandingLogo} alt="Branding Logo" className="mb-4 h-6" />
) : (
<Img
src={getAssetUrl('/static/logo.png')}
alt="Documenso Logo"
className="mb-4 h-6"
/>
)}
<TemplateVerificationCode
verificationCode={verificationCode}
assetBaseUrl={assetBaseUrl}
/>
</Section>
</Container>
<Hr className="mx-auto mt-12 max-w-xl" />
<Container className="mx-auto max-w-xl">
<TemplateFooter isDocument={false} />
</Container>
</Section>
</Body>
</Html>
);
};
export default VerificationCodeTemplate;

View File

@ -0,0 +1,120 @@
import { createElement } from 'react';
import { msg } from '@lingui/core/macro';
import { randomInt } from 'crypto';
import { AuthenticationErrorCode } from '@documenso/auth/server/lib/errors/error-codes';
import { mailer } from '@documenso/email/mailer';
import { VerificationCodeTemplate } from '@documenso/email/templates/verification-code';
import { AppError } from '@documenso/lib/errors/app-error';
import { prisma } from '@documenso/prisma';
import { getI18nInstance } from '../../client-only/providers/i18n-server';
import { NEXT_PUBLIC_WEBAPP_URL } from '../../constants/app';
import { FROM_ADDRESS, FROM_NAME } from '../../constants/email';
import { renderEmailWithI18N } from '../../utils/render-email-with-i18n';
const ExtendedAuthErrorCode = {
...AuthenticationErrorCode,
InternalError: 'INTERNAL_ERROR',
VerificationNotFound: 'VERIFICATION_NOT_FOUND',
VerificationExpired: 'VERIFICATION_EXPIRED',
};
const VERIFICATION_CODE_EXPIRY = 10 * 60 * 1000;
export type SendEmailVerificationOptions = {
userId: number;
email: string;
};
export const sendEmailVerification = async ({ userId, email }: SendEmailVerificationOptions) => {
try {
const verificationCode = randomInt(100000, 1000000).toString();
const i18n = await getI18nInstance();
await prisma.userTwoFactorEmailVerification.upsert({
where: {
userId,
},
create: {
userId,
verificationCode,
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
},
update: {
verificationCode,
expiresAt: new Date(Date.now() + VERIFICATION_CODE_EXPIRY),
},
});
const template = createElement(VerificationCodeTemplate, {
verificationCode,
assetBaseUrl: NEXT_PUBLIC_WEBAPP_URL(),
});
const [html, text] = await Promise.all([
renderEmailWithI18N(template, { lang: 'en' }),
renderEmailWithI18N(template, { lang: 'en', plainText: true }),
]);
await mailer.sendMail({
to: email,
from: {
name: FROM_NAME,
address: FROM_ADDRESS,
},
subject: i18n._(msg`Your verification code for document signing`),
html,
text,
});
return { success: true };
} catch (error) {
console.error('Error sending email verification', error);
throw new AppError(ExtendedAuthErrorCode.InternalError);
}
};
export type VerifyEmailCodeOptions = {
userId: number;
code: string;
};
export const verifyEmailCode = async ({ userId, code }: VerifyEmailCodeOptions) => {
try {
const verification = await prisma.userTwoFactorEmailVerification.findUnique({
where: {
userId,
},
});
if (!verification) {
throw new AppError(ExtendedAuthErrorCode.VerificationNotFound);
}
if (verification.expiresAt < new Date()) {
throw new AppError(ExtendedAuthErrorCode.VerificationExpired);
}
if (verification.verificationCode !== code) {
throw new AppError(AuthenticationErrorCode.InvalidTwoFactorCode);
}
await prisma.userTwoFactorEmailVerification.delete({
where: {
userId,
},
});
return { success: true };
} catch (error) {
console.error('Error verifying email code', error);
if (error instanceof AppError) {
throw error;
}
throw new AppError(ExtendedAuthErrorCode.InternalError);
}
};

View File

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "UserTwoFactorEmailVerification" (
"userId" INTEGER NOT NULL,
"verificationCode" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "UserTwoFactorEmailVerification_pkey" PRIMARY KEY ("userId")
);
-- AddForeignKey
ALTER TABLE "UserTwoFactorEmailVerification" ADD CONSTRAINT "UserTwoFactorEmailVerification_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@ -65,6 +65,7 @@ model User {
twoFactorEnabled Boolean @default(false) twoFactorEnabled Boolean @default(false)
twoFactorBackupCodes String? twoFactorBackupCodes String?
url String? @unique url String? @unique
twoFactorEmailVerification UserTwoFactorEmailVerification?
profile UserProfile? profile UserProfile?
verificationTokens VerificationToken[] verificationTokens VerificationToken[]
@ -795,3 +796,12 @@ model AvatarImage {
team Team[] team Team[]
user User[] user User[]
} }
model UserTwoFactorEmailVerification {
userId Int @id
verificationCode String
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}

View File

@ -1,5 +1,10 @@
import type { RegistrationResponseJSON } from '@simplewebauthn/types'; import type { RegistrationResponseJSON } from '@simplewebauthn/types';
import { AppError } from '@documenso/lib/errors/app-error';
import {
sendEmailVerification,
verifyEmailCode,
} from '@documenso/lib/server-only/2fa/send-email-verification';
import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey'; import { createPasskey } from '@documenso/lib/server-only/auth/create-passkey';
import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options'; import { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-options';
import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options'; import { createPasskeyRegistrationOptions } from '@documenso/lib/server-only/auth/create-passkey-registration-options';
@ -8,6 +13,7 @@ import { deletePasskey } from '@documenso/lib/server-only/auth/delete-passkey';
import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys'; import { findPasskeys } from '@documenso/lib/server-only/auth/find-passkeys';
import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey';
import { nanoid } from '@documenso/lib/universal/id'; import { nanoid } from '@documenso/lib/universal/id';
import { prisma } from '@documenso/prisma';
import { authenticatedProcedure, procedure, router } from '../trpc'; import { authenticatedProcedure, procedure, router } from '../trpc';
import { import {
@ -15,7 +21,9 @@ import {
ZCreatePasskeyMutationSchema, ZCreatePasskeyMutationSchema,
ZDeletePasskeyMutationSchema, ZDeletePasskeyMutationSchema,
ZFindPasskeysQuerySchema, ZFindPasskeysQuerySchema,
ZSendEmailVerificationMutationSchema,
ZUpdatePasskeyMutationSchema, ZUpdatePasskeyMutationSchema,
ZVerifyEmailCodeMutationSchema,
} from './schema'; } from './schema';
export const authRouter = router({ export const authRouter = router({
@ -98,4 +106,68 @@ export const authRouter = router({
requestMetadata: ctx.metadata.requestMetadata, requestMetadata: ctx.metadata.requestMetadata,
}); });
}), }),
// Email verification for document signing
sendEmailVerification: authenticatedProcedure
.input(ZSendEmailVerificationMutationSchema)
.mutation(async ({ ctx, input }) => {
const { recipientId } = input;
const userId = ctx.user.id;
let email = ctx.user.email;
// If recipientId is provided, fetch that recipient's details
if (recipientId) {
const recipient = await prisma.recipient.findUnique({
where: {
id: recipientId,
},
select: {
email: true,
},
});
if (!recipient) {
throw new AppError('NOT_FOUND', {
message: 'Recipient not found',
});
}
email = recipient.email;
}
return sendEmailVerification({
userId,
email,
});
}),
verifyEmailCode: authenticatedProcedure
.input(ZVerifyEmailCodeMutationSchema)
.mutation(async ({ ctx, input }) => {
const { code, recipientId } = input;
const userId = ctx.user.id;
// If recipientId is provided, check that the user has access to it
if (recipientId) {
const recipient = await prisma.recipient.findUnique({
where: {
id: recipientId,
},
select: {
email: true,
},
});
if (!recipient) {
throw new AppError('NOT_FOUND', {
message: 'Recipient not found',
});
}
}
return verifyEmailCode({
userId,
code,
});
}),
}); });

View File

@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({
}); });
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>; export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
export const ZSendEmailVerificationMutationSchema = z.object({
recipientId: z.number().optional(),
});
export type TSendEmailVerificationMutationSchema = z.infer<
typeof ZSendEmailVerificationMutationSchema
>;
export const ZVerifyEmailCodeMutationSchema = z.object({
code: z.string().min(6).max(6),
recipientId: z.number().optional(),
});
export type TVerifyEmailCodeMutationSchema = z.infer<typeof ZVerifyEmailCodeMutationSchema>;