mirror of
https://github.com/documenso/documenso.git
synced 2025-11-14 00:32:43 +10:00
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.
This commit is contained in:
1
packages/lib/server-only/2fa/email/constants.ts
Normal file
1
packages/lib/server-only/2fa/email/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const TWO_FACTOR_EMAIL_EXPIRATION_MINUTES = 5;
|
||||
@ -0,0 +1,38 @@
|
||||
import { hmac } from '@noble/hashes/hmac';
|
||||
import { sha256 } from '@noble/hashes/sha256';
|
||||
import { createTOTPKeyURI } from 'oslo/otp';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '../../../constants/crypto';
|
||||
|
||||
const ISSUER = 'Documenso Email 2FA';
|
||||
|
||||
export type GenerateTwoFactorCredentialsFromEmailOptions = {
|
||||
documentId: number;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate an encrypted token containing a 6-digit 2FA code for email verification.
|
||||
*
|
||||
* @param options - The options for generating the token
|
||||
* @returns Object containing the token and the 6-digit code
|
||||
*/
|
||||
export const generateTwoFactorCredentialsFromEmail = ({
|
||||
documentId,
|
||||
email,
|
||||
}: GenerateTwoFactorCredentialsFromEmailOptions) => {
|
||||
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,
|
||||
};
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
124
packages/lib/server-only/2fa/email/send-2fa-token-email.ts
Normal file
124
packages/lib/server-only/2fa/email/send-2fa-token-email.ts
Normal file
@ -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 },
|
||||
);
|
||||
};
|
||||
@ -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;
|
||||
};
|
||||
@ -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({
|
||||
|
||||
@ -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, 'authOptions' | 'email'>;
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||
|
||||
/**
|
||||
* 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,
|
||||
|
||||
@ -7,7 +7,7 @@ import { isRecipientAuthorized } from './is-recipient-authorized';
|
||||
|
||||
export type ValidateFieldAuthOptions = {
|
||||
documentAuthOptions: Document['authOptions'];
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email'>;
|
||||
recipient: Pick<Recipient, 'authOptions' | 'email' | 'documentId'>;
|
||||
field: Field;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
|
||||
@ -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,
|
||||
|
||||
Reference in New Issue
Block a user