mirror of
https://github.com/documenso/documenso.git
synced 2026-07-25 09:25:08 +10:00
chore: merge main, resolve biome formatting conflicts
Merge origin/main into feat/external-2fa-codes. Resolve formatting conflicts caused by biome rollout; preserve both feature streams: PR's external 2FA token + signing-session 2FA proof additions plus main's RateLimit/RecipientExpired/signingReminders/date-auto-insert. In complete-document-with-token.ts, drop the duplicate early field-fetching block introduced when main moved that logic later with date auto-insert support; keep the EXTERNAL_TWO_FACTOR_AUTH check using derivedRecipientActionAuth.
This commit is contained in:
@@ -1,8 +1,4 @@
|
||||
import { CreateEmailIdentityCommand, SESv2Client } from '@aws-sdk/client-sesv2';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { generateKeyPair } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricEncrypt } from '@documenso/lib/universal/crypto';
|
||||
@@ -10,6 +6,9 @@ import { generateDatabaseId } from '@documenso/lib/universal/id';
|
||||
import { generateEmailDomainRecords } from '@documenso/lib/utils/email-domains';
|
||||
import { env } from '@documenso/lib/utils/env';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
import { generateKeyPair } from 'crypto';
|
||||
import { promisify } from 'util';
|
||||
|
||||
export const getSesClient = () => {
|
||||
const accessKeyId = env('NEXT_PRIVATE_SES_ACCESS_KEY_ID');
|
||||
@@ -111,40 +110,40 @@ export const createEmailDomain = async ({ domain, organisationId }: CreateEmailD
|
||||
data: privateKeyFlattened,
|
||||
});
|
||||
|
||||
const emailDomain = await prisma.$transaction(async (tx) => {
|
||||
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
|
||||
if (err.name === 'AlreadyExistsException') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Domain already exists in SES',
|
||||
});
|
||||
}
|
||||
// Verify domain with SES outside a transaction to avoid holding a
|
||||
// connection open during the external API call.
|
||||
await verifyDomainWithDKIM(domain, selector, privateKeyFlattened).catch((err) => {
|
||||
if (err.name === 'AlreadyExistsException') {
|
||||
throw new AppError(AppErrorCode.ALREADY_EXISTS, {
|
||||
message: 'Domain already exists in SES',
|
||||
});
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Create email domain record.
|
||||
return await tx.emailDomain.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_domain'),
|
||||
domain,
|
||||
status: EmailDomainStatus.PENDING,
|
||||
organisationId,
|
||||
selector: recordName,
|
||||
publicKey: publicKeyFlattened,
|
||||
privateKey: encryptedPrivateKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
organisationId: true,
|
||||
domain: true,
|
||||
selector: true,
|
||||
publicKey: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
emails: true,
|
||||
},
|
||||
});
|
||||
const emailDomain = await prisma.emailDomain.create({
|
||||
data: {
|
||||
id: generateDatabaseId('email_domain'),
|
||||
domain,
|
||||
status: EmailDomainStatus.PENDING,
|
||||
organisationId,
|
||||
selector: recordName,
|
||||
publicKey: publicKeyFlattened,
|
||||
privateKey: encryptedPrivateKey,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
organisationId: true,
|
||||
domain: true,
|
||||
selector: true,
|
||||
publicKey: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
lastVerifiedAt: true,
|
||||
emails: true,
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
import { getOrganisationAuthenticationPortalOptions } from '@documenso/auth/server/lib/utils/organisation-portal';
|
||||
import { IS_BILLING_ENABLED } from '@documenso/lib/constants/app';
|
||||
import {
|
||||
@@ -11,16 +9,14 @@ import { addUserToOrganisation } from '@documenso/lib/server-only/organisation/a
|
||||
import { ZOrganisationAccountLinkMetadataSchema } from '@documenso/lib/types/organisation';
|
||||
import type { RequestMetadata } from '@documenso/lib/universal/extract-request-metadata';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { UserSecurityAuditLogType } from '@prisma/client';
|
||||
|
||||
export interface LinkOrganisationAccountOptions {
|
||||
token: string;
|
||||
requestMeta: RequestMetadata;
|
||||
}
|
||||
|
||||
export const linkOrganisationAccount = async ({
|
||||
token,
|
||||
requestMeta,
|
||||
}: LinkOrganisationAccountOptions) => {
|
||||
export const linkOrganisationAccount = async ({ token, requestMeta }: LinkOrganisationAccountOptions) => {
|
||||
if (!IS_BILLING_ENABLED()) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: 'Billing is not enabled',
|
||||
@@ -65,9 +61,7 @@ export const linkOrganisationAccount = async ({
|
||||
});
|
||||
}
|
||||
|
||||
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(
|
||||
verificationToken.metadata,
|
||||
);
|
||||
const tokenMetadata = ZOrganisationAccountLinkMetadataSchema.safeParse(verificationToken.metadata);
|
||||
|
||||
if (!tokenMetadata.success) {
|
||||
console.error('Invalid token metadata', tokenMetadata.error);
|
||||
@@ -94,70 +88,66 @@ export const linkOrganisationAccount = async ({
|
||||
const oauthConfig = tokenMetadata.data.oauthConfig;
|
||||
|
||||
const userAlreadyLinked = user.accounts.find(
|
||||
(account) =>
|
||||
account.provider === clientOptions.id &&
|
||||
account.providerAccountId === oauthConfig.providerAccountId,
|
||||
(account) => account.provider === clientOptions.id && account.providerAccountId === oauthConfig.providerAccountId,
|
||||
);
|
||||
|
||||
if (organisationMember && userAlreadyLinked) {
|
||||
return;
|
||||
}
|
||||
|
||||
await prisma.$transaction(
|
||||
async (tx) => {
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
// Link the user if not linked yet.
|
||||
if (!userAlreadyLinked) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.account.create({
|
||||
data: {
|
||||
type: ORGANISATION_USER_ACCOUNT_TYPE,
|
||||
provider: clientOptions.id,
|
||||
providerAccountId: oauthConfig.providerAccountId,
|
||||
access_token: oauthConfig.accessToken,
|
||||
expires_at: oauthConfig.expiresAt,
|
||||
token_type: 'Bearer',
|
||||
id_token: oauthConfig.idToken,
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole:
|
||||
organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
},
|
||||
});
|
||||
|
||||
// Log link event.
|
||||
await tx.userSecurityAuditLog.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
ipAddress: requestMeta.ipAddress,
|
||||
userAgent: requestMeta.userAgent,
|
||||
type: UserSecurityAuditLogType.ORGANISATION_SSO_LINK,
|
||||
},
|
||||
});
|
||||
|
||||
// If account already exists in an unverified state, remove the password to ensure
|
||||
// they cannot sign in using that method since we cannot confirm the password
|
||||
// was set by the user.
|
||||
if (!user.emailVerified) {
|
||||
await tx.user.update({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
data: {
|
||||
emailVerified: new Date(),
|
||||
password: null,
|
||||
// Todo: (RR7) Will need to update the "password" account after the migration.
|
||||
},
|
||||
});
|
||||
}
|
||||
},
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Only add the user to the organisation if they are not already a member.
|
||||
// Done outside the above transaction to avoid nested transactions and
|
||||
// holding connections during the job trigger network I/O.
|
||||
if (!organisationMember) {
|
||||
await addUserToOrganisation({
|
||||
userId: user.id,
|
||||
organisationId: tokenMetadata.data.organisationId,
|
||||
organisationGroups: organisation.groups,
|
||||
organisationMemberRole: organisation.organisationAuthenticationPortal.defaultOrganisationRole,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import { DeleteEmailIdentityCommand } from '@aws-sdk/client-sesv2';
|
||||
import { DOCUMENSO_ENCRYPTION_KEY } from '@documenso/lib/constants/crypto';
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { symmetricDecrypt } from '@documenso/lib/universal/crypto';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
|
||||
import { getSesClient, verifyDomainWithDKIM } from './create-email-domain';
|
||||
|
||||
type ReregisterEmailDomainOptions = {
|
||||
emailDomainId: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Re-register an email domain in SES using the same DKIM key pair.
|
||||
*
|
||||
* This deletes the existing SES identity and recreates it with the same
|
||||
* selector and private key, so the user does not need to update their DNS records.
|
||||
*
|
||||
* Permission is assumed to be checked in the caller.
|
||||
*/
|
||||
export const reregisterEmailDomain = async ({ emailDomainId }: ReregisterEmailDomainOptions) => {
|
||||
const encryptionKey = DOCUMENSO_ENCRYPTION_KEY;
|
||||
|
||||
if (!encryptionKey) {
|
||||
throw new Error('Missing DOCUMENSO_ENCRYPTION_KEY');
|
||||
}
|
||||
|
||||
const emailDomain = await prisma.emailDomain.findUnique({
|
||||
where: {
|
||||
id: emailDomainId,
|
||||
},
|
||||
});
|
||||
|
||||
if (!emailDomain) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Email domain not found',
|
||||
});
|
||||
}
|
||||
|
||||
const sesClient = getSesClient();
|
||||
|
||||
// Delete the existing SES identity, ignoring if it no longer exists.
|
||||
await sesClient
|
||||
.send(
|
||||
new DeleteEmailIdentityCommand({
|
||||
EmailIdentity: emailDomain.domain,
|
||||
}),
|
||||
)
|
||||
.catch((err) => {
|
||||
if (err.name === 'NotFoundException') {
|
||||
return;
|
||||
}
|
||||
|
||||
throw err;
|
||||
});
|
||||
|
||||
// Decrypt the stored private key.
|
||||
const decryptedPrivateKeyBytes = symmetricDecrypt({
|
||||
key: encryptionKey,
|
||||
data: emailDomain.privateKey,
|
||||
});
|
||||
|
||||
const decryptedPrivateKey = new TextDecoder().decode(decryptedPrivateKeyBytes);
|
||||
|
||||
// The selector field in the DB is the full record name (e.g. "documenso-orgid._domainkey.example.com").
|
||||
// We need to extract just the selector part (before "._domainkey.").
|
||||
const selectorParts = emailDomain.selector.split('._domainkey.');
|
||||
const selector = selectorParts[0];
|
||||
|
||||
if (!selector) {
|
||||
throw new AppError(AppErrorCode.UNKNOWN_ERROR, {
|
||||
message: 'Could not extract selector from email domain record',
|
||||
});
|
||||
}
|
||||
|
||||
// Recreate the SES identity with the same DKIM key pair.
|
||||
await verifyDomainWithDKIM(emailDomain.domain, selector, decryptedPrivateKey);
|
||||
|
||||
// Reset status to PENDING and update lastVerifiedAt.
|
||||
const updatedEmailDomain = await prisma.emailDomain.update({
|
||||
where: {
|
||||
id: emailDomainId,
|
||||
},
|
||||
data: {
|
||||
status: EmailDomainStatus.PENDING,
|
||||
lastVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
return updatedEmailDomain;
|
||||
};
|
||||
@@ -1,9 +1,3 @@
|
||||
import { createElement } from 'react';
|
||||
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
|
||||
import { mailer } from '@documenso/email/mailer';
|
||||
import { OrganisationAccountLinkConfirmationTemplate } from '@documenso/email/templates/organisation-account-link-confirmation';
|
||||
import { getI18nInstance } from '@documenso/lib/client-only/providers/i18n-server';
|
||||
@@ -15,6 +9,10 @@ import { getEmailContext } from '@documenso/lib/server-only/email/get-email-cont
|
||||
import type { TOrganisationAccountLinkMetadata } from '@documenso/lib/types/organisation';
|
||||
import { renderEmailWithI18N } from '@documenso/lib/utils/render-email-with-i18n';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { msg } from '@lingui/core/macro';
|
||||
import crypto from 'crypto';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createElement } from 'react';
|
||||
|
||||
export type SendOrganisationAccountLinkConfirmationEmailProps = TOrganisationAccountLinkMetadata & {
|
||||
organisationName: string;
|
||||
@@ -109,10 +107,7 @@ export const sendOrganisationAccountLinkConfirmationEmail = async ({
|
||||
name: user.name || '',
|
||||
},
|
||||
from: DOCUMENSO_INTERNAL_EMAIL,
|
||||
subject:
|
||||
type === 'create'
|
||||
? i18n._(msg`Account creation request`)
|
||||
: i18n._(msg`Account linking request`),
|
||||
subject: type === 'create' ? i18n._(msg`Account creation request`) : i18n._(msg`Account linking request`),
|
||||
html,
|
||||
text,
|
||||
});
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { GetEmailIdentityCommand } from '@aws-sdk/client-sesv2';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { EmailDomainStatus } from '@prisma/client';
|
||||
|
||||
import { getSesClient } from './create-email-domain';
|
||||
|
||||
@@ -35,6 +34,7 @@ export const verifyEmailDomain = async (emailDomainId: string) => {
|
||||
},
|
||||
data: {
|
||||
status: isVerified ? EmailDomainStatus.ACTIVE : EmailDomainStatus.PENDING,
|
||||
lastVerifiedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user