mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +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;
|
||||
};
|
||||
Reference in New Issue
Block a user