diff --git a/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx index dd693e4e9..7c5fbf439 100644 --- a/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx +++ b/apps/remix/app/components/general/document-signing/document-signing-auth-2fa.tsx @@ -8,6 +8,7 @@ import { z } from 'zod'; import { AppError } from '@documenso/lib/errors/app-error'; 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 { Button } from '@documenso/ui/primitives/button'; import { DialogFooter } from '@documenso/ui/primitives/dialog'; @@ -20,6 +21,8 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; 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'; @@ -51,6 +54,7 @@ export const DocumentSigningAuth2FA = ({ }: DocumentSigningAuth2FAProps) => { const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } = useRequiredDocumentSigningAuthContext(); + const { toast } = useToast(); const form = useForm({ resolver: zodResolver(Z2FAAuthFormSchema), @@ -60,27 +64,71 @@ export const DocumentSigningAuth2FA = ({ }); const [is2FASetupSuccessful, setIs2FASetupSuccessful] = useState(false); + const [isEmailCodeSent, setIsEmailCodeSent] = useState(false); + const [isEmailCodeSending, setIsEmailCodeSending] = useState(false); const [formErrorCode, setFormErrorCode] = useState(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) => { try { setIsCurrentlyAuthenticating(true); + if (verificationMethod === 'email') { + // Verify the email code first + await verifyCodeMutation.mutateAsync({ + code: token, + recipientId: recipient.id, + }); + } + 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. } }; @@ -91,20 +139,40 @@ export const DocumentSigningAuth2FA = ({ setIs2FASetupSuccessful(false); setFormErrorCode(null); + setIsEmailCodeSent(false); + + if (open && !user?.twoFactorEnabled) { + setVerificationMethod('email'); + } // 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 (
+ setVerificationMethod(val as 'app' | 'email')} + > + + Authenticator App + Email Verification + + +

{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT' ? ( You need to setup 2FA to mark this document as viewed. ) : ( - // Todo: Translate `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.` )}

@@ -129,59 +197,113 @@ export const DocumentSigningAuth2FA = ({ } return ( -
- -
-
- ( - - 2FA token +
+ {user?.twoFactorEnabled && ( + setVerificationMethod(val as 'app' | 'email')} + > + + Authenticator App + Email Verification + + + )} - - - {Array(6) - .fill(null) - .map((_, i) => ( - - - - ))} - - - - - - )} - /> - - {formErrorCode && ( - - - Unauthorized - - - - We were unable to verify your details. Please try again or contact support - - - + {verificationMethod === 'email' && ( + + + {isEmailCodeSent ? ( +

+ + A verification code has been sent to {recipient.email}. Please enter it below to + continue. + +

+ ) : ( +

+ + We'll send a verification code to {recipient.email} to verify your identity. + +

)} +
+
+ )} - - + + +
+
+ ( + + + {verificationMethod === 'app' ? ( + 2FA token + ) : ( + Verification code + )} + - - -
-
- - + + + {Array(6) + .fill(null) + .map((_, i) => ( + + + + ))} + + + + + + )} + /> + + {verificationMethod === 'email' && ( +
+ +
+ )} + + {formErrorCode && ( + + + Unauthorized + + + + We were unable to verify your details. Please try again or contact support + + + + )} + + + + + + +
+
+ + +
); }; diff --git a/packages/email/template-components/template-verification-code.tsx b/packages/email/template-components/template-verification-code.tsx new file mode 100644 index 000000000..690533dba --- /dev/null +++ b/packages/email/template-components/template-verification-code.tsx @@ -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 ( + <> + + +
+ + Your verification code + + + + Please use the code below to verify your identity for document signing. + + + + {verificationCode} + + + + + If you did not request this code, you can ignore this email. The code will expire after + 10 minutes. + + +
+ + ); +}; + +export default TemplateVerificationCode; diff --git a/packages/email/templates/verification-code.tsx b/packages/email/templates/verification-code.tsx new file mode 100644 index 000000000..a93f7d79d --- /dev/null +++ b/packages/email/templates/verification-code.tsx @@ -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; + +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 ( + + + {_(previewText)} + + +
+ +
+ {branding.brandingEnabled && branding.brandingLogo ? ( + Branding Logo + ) : ( + Documenso Logo + )} + + +
+
+ +
+ + + + +
+ + + ); +}; + +export default VerificationCodeTemplate; diff --git a/packages/lib/server-only/2fa/send-email-verification.ts b/packages/lib/server-only/2fa/send-email-verification.ts new file mode 100644 index 000000000..bccf5fd43 --- /dev/null +++ b/packages/lib/server-only/2fa/send-email-verification.ts @@ -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); + } +}; diff --git a/packages/prisma/migrations/20250430202737_add_email_migration/migration.sql b/packages/prisma/migrations/20250430202737_add_email_migration/migration.sql new file mode 100644 index 000000000..0ccfc9012 --- /dev/null +++ b/packages/prisma/migrations/20250430202737_add_email_migration/migration.sql @@ -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; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 04b60843f..2fb7fe8c0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -53,18 +53,19 @@ model User { avatarImageId String? disabled Boolean @default(false) - accounts Account[] - sessions Session[] - documents Document[] - subscriptions Subscription[] - passwordResetTokens PasswordResetToken[] - ownedTeams Team[] - ownedPendingTeams TeamPending[] - teamMembers TeamMember[] - twoFactorSecret String? - twoFactorEnabled Boolean @default(false) - twoFactorBackupCodes String? - url String? @unique + accounts Account[] + sessions Session[] + documents Document[] + subscriptions Subscription[] + passwordResetTokens PasswordResetToken[] + ownedTeams Team[] + ownedPendingTeams TeamPending[] + teamMembers TeamMember[] + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + twoFactorBackupCodes String? + url String? @unique + twoFactorEmailVerification UserTwoFactorEmailVerification? profile UserProfile? verificationTokens VerificationToken[] @@ -795,3 +796,12 @@ model AvatarImage { team Team[] user User[] } + +model UserTwoFactorEmailVerification { + userId Int @id + verificationCode String + expiresAt DateTime + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index b35fd160e..4dabf88b4 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,5 +1,10 @@ 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 { createPasskeyAuthenticationOptions } from '@documenso/lib/server-only/auth/create-passkey-authentication-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 { updatePasskey } from '@documenso/lib/server-only/auth/update-passkey'; import { nanoid } from '@documenso/lib/universal/id'; +import { prisma } from '@documenso/prisma'; import { authenticatedProcedure, procedure, router } from '../trpc'; import { @@ -15,7 +21,9 @@ import { ZCreatePasskeyMutationSchema, ZDeletePasskeyMutationSchema, ZFindPasskeysQuerySchema, + ZSendEmailVerificationMutationSchema, ZUpdatePasskeyMutationSchema, + ZVerifyEmailCodeMutationSchema, } from './schema'; export const authRouter = router({ @@ -98,4 +106,68 @@ export const authRouter = router({ 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, + }); + }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index 55ea2167d..7e6d91027 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -71,3 +71,18 @@ export const ZFindPasskeysQuerySchema = ZFindSearchParamsSchema.extend({ }); export type TSignUpMutationSchema = z.infer; + +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;