mirror of
https://github.com/documenso/documenso.git
synced 2026-07-22 16:03:39 +10:00
refactor(signing-2fa): simplify server-side and UI code for external 2FA
- Extract throwVerificationError helper in verify-signing-two-factor-token.ts - Extract throwIssuanceDenied helper in issue-signing-two-factor-token.ts - Eliminate duplicated attemptsRemaining state in UI component - Use imported SIGNING_2FA_VERIFY_REASON_CODES constants - Add statusQuery.refetch() after failed verify for single source of truth - Fix TypeScript control flow with explicit returns after throws
This commit is contained in:
@@ -0,0 +1,338 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { FieldType } from '@prisma/client';
|
||||
|
||||
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
|
||||
import {
|
||||
createDocumentAuthOptions,
|
||||
createRecipientAuthOptions,
|
||||
} from '@documenso/lib/utils/document-auth';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
import { seedPendingDocumentWithFullFields } from '@documenso/prisma/seed/documents';
|
||||
import { seedTestEmail, seedUser } from '@documenso/prisma/seed/users';
|
||||
|
||||
import { signSignaturePad } from '../fixtures/signature';
|
||||
|
||||
test.describe.configure({ mode: 'parallel', timeout: 60000 });
|
||||
|
||||
const seedExternal2FADocument = async (recipientEmail?: string) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
|
||||
const recipientArg = recipientEmail ?? seedTestEmail();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner,
|
||||
teamId: team.id,
|
||||
recipients: [recipientArg],
|
||||
recipientsCreateOptions: [
|
||||
{
|
||||
authOptions: createRecipientAuthOptions({
|
||||
accessAuth: [],
|
||||
actionAuth: ['EXTERNAL_TWO_FACTOR_AUTH'],
|
||||
}),
|
||||
},
|
||||
],
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name: 'test-2fa-token',
|
||||
token: `test-${Date.now()}-${Math.random()}`,
|
||||
teamId: team.id,
|
||||
userId: owner.id,
|
||||
},
|
||||
});
|
||||
|
||||
return { owner, team, document, recipient: recipients[0], apiToken };
|
||||
};
|
||||
|
||||
test('[EXTERNAL_2FA]: should allow signing when valid code is entered', async ({ page }) => {
|
||||
const { document, recipient, apiToken } = await seedExternal2FADocument();
|
||||
|
||||
const { token: plaintextCode } = await issueSigningTwoFactorToken({
|
||||
envelopeId: document.id,
|
||||
recipientId: recipient.id,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('No signature field found');
|
||||
}
|
||||
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.getByText('Verification code')).toBeVisible();
|
||||
|
||||
const pinInputs = page.locator('input[data-input-otp-placeholder]');
|
||||
await pinInputs.first().click();
|
||||
|
||||
for (const digit of plaintextCode.split('')) {
|
||||
await page.keyboard.type(digit);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Verify' }).click();
|
||||
|
||||
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute(
|
||||
'data-inserted',
|
||||
'true',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
});
|
||||
|
||||
test('[EXTERNAL_2FA]: should deny signing field when no token has been issued', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { recipient } = await seedExternal2FADocument();
|
||||
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('No signature field found');
|
||||
}
|
||||
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.getByText('Verification code required')).toBeVisible();
|
||||
await expect(page.getByText('Please contact the document sender')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[EXTERNAL_2FA]: should show error when invalid code is entered', async ({ page }) => {
|
||||
const { document, recipient, apiToken } = await seedExternal2FADocument();
|
||||
|
||||
await issueSigningTwoFactorToken({
|
||||
envelopeId: document.id,
|
||||
recipientId: recipient.id,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('No signature field found');
|
||||
}
|
||||
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.getByText('Verification code')).toBeVisible();
|
||||
|
||||
const pinInputs = page.locator('input[data-input-otp-placeholder]');
|
||||
await pinInputs.first().click();
|
||||
|
||||
for (const digit of '000000'.split('')) {
|
||||
await page.keyboard.type(digit);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Verify' }).click();
|
||||
|
||||
await expect(page.getByText('Verification failed')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('Invalid code')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[EXTERNAL_2FA]: should show expired error when token has expired', async ({ page }) => {
|
||||
const { document, recipient, apiToken } = await seedExternal2FADocument();
|
||||
|
||||
await issueSigningTwoFactorToken({
|
||||
envelopeId: document.id,
|
||||
recipientId: recipient.id,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
await prisma.signingTwoFactorToken.updateMany({
|
||||
where: {
|
||||
recipientId: recipient.id,
|
||||
envelopeId: document.id,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
data: {
|
||||
expiresAt: new Date(Date.now() - 60_000),
|
||||
},
|
||||
});
|
||||
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('No signature field found');
|
||||
}
|
||||
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.getByText('Verification code')).toBeVisible();
|
||||
|
||||
const pinInputs = page.locator('input[data-input-otp-placeholder]');
|
||||
await pinInputs.first().click();
|
||||
|
||||
for (const digit of '123456'.split('')) {
|
||||
await page.keyboard.type(digit);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Verify' }).click();
|
||||
|
||||
await expect(page.getByText('Verification failed')).toBeVisible({ timeout: 10000 });
|
||||
await expect(page.getByText('code has expired')).toBeVisible();
|
||||
});
|
||||
|
||||
test('[EXTERNAL_2FA]: should allow signing with global action auth on document', async ({
|
||||
page,
|
||||
}) => {
|
||||
const { user: owner, team } = await seedUser();
|
||||
|
||||
const { recipients, document } = await seedPendingDocumentWithFullFields({
|
||||
owner,
|
||||
teamId: team.id,
|
||||
recipients: [seedTestEmail()],
|
||||
recipientsCreateOptions: [
|
||||
{
|
||||
authOptions: createRecipientAuthOptions({
|
||||
accessAuth: [],
|
||||
actionAuth: [],
|
||||
}),
|
||||
},
|
||||
],
|
||||
updateDocumentOptions: {
|
||||
authOptions: createDocumentAuthOptions({
|
||||
globalAccessAuth: [],
|
||||
globalActionAuth: ['EXTERNAL_TWO_FACTOR_AUTH'],
|
||||
}),
|
||||
},
|
||||
fields: [FieldType.SIGNATURE],
|
||||
});
|
||||
|
||||
const recipient = recipients[0];
|
||||
|
||||
const apiToken = await prisma.apiToken.create({
|
||||
data: {
|
||||
name: 'test-2fa-global',
|
||||
token: `test-global-${Date.now()}-${Math.random()}`,
|
||||
teamId: team.id,
|
||||
userId: owner.id,
|
||||
},
|
||||
});
|
||||
|
||||
const { token: plaintextCode } = await issueSigningTwoFactorToken({
|
||||
envelopeId: document.id,
|
||||
recipientId: recipient.id,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('No signature field found');
|
||||
}
|
||||
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.getByText('Verification code')).toBeVisible();
|
||||
|
||||
const pinInputs = page.locator('input[data-input-otp-placeholder]');
|
||||
await pinInputs.first().click();
|
||||
|
||||
for (const digit of plaintextCode.split('')) {
|
||||
await page.keyboard.type(digit);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Verify' }).click();
|
||||
|
||||
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute(
|
||||
'data-inserted',
|
||||
'true',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
|
||||
await page.getByRole('button', { name: 'Complete' }).click();
|
||||
await page.getByRole('button', { name: 'Sign' }).click();
|
||||
await page.waitForURL(`${signUrl}/complete`);
|
||||
});
|
||||
|
||||
test('[EXTERNAL_2FA]: should revoke old token when a new one is issued', async ({ page }) => {
|
||||
const { document, recipient, apiToken } = await seedExternal2FADocument();
|
||||
|
||||
await issueSigningTwoFactorToken({
|
||||
envelopeId: document.id,
|
||||
recipientId: recipient.id,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
const { token: newCode } = await issueSigningTwoFactorToken({
|
||||
envelopeId: document.id,
|
||||
recipientId: recipient.id,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
const revokedTokens = await prisma.signingTwoFactorToken.findMany({
|
||||
where: {
|
||||
recipientId: recipient.id,
|
||||
envelopeId: document.id,
|
||||
status: 'REVOKED',
|
||||
},
|
||||
});
|
||||
|
||||
expect(revokedTokens.length).toBe(1);
|
||||
|
||||
const signUrl = `/sign/${recipient.token}`;
|
||||
|
||||
await page.goto(signUrl);
|
||||
await expect(page.getByRole('heading', { name: 'Sign Document' })).toBeVisible();
|
||||
|
||||
await signSignaturePad(page);
|
||||
|
||||
const signatureField = recipient.fields.find((f) => f.type === FieldType.SIGNATURE);
|
||||
|
||||
if (!signatureField) {
|
||||
throw new Error('No signature field found');
|
||||
}
|
||||
|
||||
await page.locator(`#field-${signatureField.id}`).getByRole('button').click();
|
||||
|
||||
await expect(page.getByText('Verification code')).toBeVisible();
|
||||
|
||||
const pinInputs = page.locator('input[data-input-otp-placeholder]');
|
||||
await pinInputs.first().click();
|
||||
|
||||
for (const digit of newCode.split('')) {
|
||||
await page.keyboard.type(digit);
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Verify' }).click();
|
||||
|
||||
await expect(page.locator(`#field-${signatureField.id}`)).toHaveAttribute(
|
||||
'data-inserted',
|
||||
'true',
|
||||
{ timeout: 10000 },
|
||||
);
|
||||
});
|
||||
@@ -26,6 +26,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
||||
key: DocumentAuth.PASSWORD,
|
||||
value: msg`Require password`,
|
||||
},
|
||||
[DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH]: {
|
||||
key: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
value: msg`Require external 2FA`,
|
||||
},
|
||||
[DocumentAuth.EXPLICIT_NONE]: {
|
||||
key: DocumentAuth.EXPLICIT_NONE,
|
||||
value: msg`None (Overrides global settings)`,
|
||||
|
||||
@@ -155,12 +155,28 @@ export const completeDocumentWithToken = async ({
|
||||
});
|
||||
}
|
||||
|
||||
// Check ACCESS AUTH 2FA validation during document completion
|
||||
const { derivedRecipientAccessAuth } = extractDocumentAuthMethods({
|
||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
if (derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH)) {
|
||||
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
|
||||
where: {
|
||||
sessionId: token,
|
||||
envelopeId: envelope.id,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
if (!validProof) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'External 2FA verification required before completing document',
|
||||
statusCode: 403,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (derivedRecipientAccessAuth.includes(DocumentAuth.TWO_FACTOR_AUTH)) {
|
||||
if (!accessAuthOptions) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
|
||||
@@ -33,6 +33,7 @@ type IsRecipientAuthorizedOptions = {
|
||||
* using the user ID.
|
||||
*/
|
||||
authOptions?: TDocumentAuthMethods;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
const getUserByEmail = async (email: string) => {
|
||||
@@ -58,6 +59,7 @@ export const isRecipientAuthorized = async ({
|
||||
recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: documentAuthOptions,
|
||||
@@ -168,6 +170,21 @@ export const isRecipientAuthorized = async ({
|
||||
password,
|
||||
});
|
||||
})
|
||||
.with({ type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, async () => {
|
||||
if (!recipientToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const validProof = await prisma.signingSessionTwoFactorProof.findFirst({
|
||||
where: {
|
||||
sessionId: recipientToken,
|
||||
envelopeId: recipient.envelopeId,
|
||||
expiresAt: { gt: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
return !!validProof;
|
||||
})
|
||||
.with({ type: DocumentAuth.EXPLICIT_NONE }, () => {
|
||||
return true;
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ export type ValidateFieldAuthOptions = {
|
||||
field: Field;
|
||||
userId?: number;
|
||||
authOptions?: TRecipientActionAuth;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -24,6 +25,7 @@ export const validateFieldAuth = async ({
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
}: ValidateFieldAuthOptions) => {
|
||||
// Override all non-signature fields to not require any auth.
|
||||
if (field.type !== FieldType.SIGNATURE) {
|
||||
@@ -36,6 +38,7 @@ export const validateFieldAuth = async ({
|
||||
recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
});
|
||||
|
||||
if (!isValid) {
|
||||
|
||||
@@ -177,6 +177,7 @@ export const signFieldWithToken = async ({
|
||||
field,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken: token,
|
||||
});
|
||||
|
||||
const documentMeta = await prisma.documentMeta.findFirst({
|
||||
|
||||
@@ -100,6 +100,7 @@ export const generateCertificatePdf = async (options: GenerateCertificatePdfOpti
|
||||
let authLevel = match(actionAuthMethod)
|
||||
.with('ACCOUNT', () => i18n._(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => i18n._(msg`Two-Factor Re-Authentication`))
|
||||
.with('EXTERNAL_TWO_FACTOR_AUTH', () => i18n._(msg`External Two-Factor Re-Authentication`))
|
||||
.with('PASSWORD', () => i18n._(msg`Password Re-Authentication`))
|
||||
.with('PASSKEY', () => i18n._(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => i18n._(msg`Email`))
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
|
||||
export type GetSigningTwoFactorStatusOptions = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type SigningTwoFactorStatus = {
|
||||
required: boolean;
|
||||
hasActiveToken: boolean;
|
||||
hasValidProof: boolean;
|
||||
tokenExpiresAt: Date | null;
|
||||
proofExpiresAt: Date | null;
|
||||
attemptsRemaining: number | null;
|
||||
};
|
||||
|
||||
const NOT_REQUIRED_STATUS: SigningTwoFactorStatus = {
|
||||
required: false,
|
||||
hasActiveToken: false,
|
||||
hasValidProof: false,
|
||||
tokenExpiresAt: null,
|
||||
proofExpiresAt: null,
|
||||
attemptsRemaining: null,
|
||||
};
|
||||
|
||||
export const getSigningTwoFactorStatus = async ({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
sessionId,
|
||||
}: GetSigningTwoFactorStatusOptions): Promise<SigningTwoFactorStatus> => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: { id: envelopeId },
|
||||
select: {
|
||||
authOptions: true,
|
||||
recipients: {
|
||||
where: { id: recipientId },
|
||||
select: {
|
||||
authOptions: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope || envelope.recipients.length === 0) {
|
||||
return NOT_REQUIRED_STATUS;
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const required = derivedRecipientActionAuth.includes(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH);
|
||||
|
||||
if (!required) {
|
||||
return NOT_REQUIRED_STATUS;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
const [activeToken, validProof] = await Promise.all([
|
||||
prisma.signingTwoFactorToken.findFirst({
|
||||
where: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
status: 'ACTIVE',
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
select: {
|
||||
expiresAt: true,
|
||||
attempts: true,
|
||||
attemptLimit: true,
|
||||
},
|
||||
}),
|
||||
prisma.signingSessionTwoFactorProof.findFirst({
|
||||
where: {
|
||||
sessionId,
|
||||
recipientId,
|
||||
envelopeId,
|
||||
expiresAt: { gt: now },
|
||||
},
|
||||
select: {
|
||||
expiresAt: true,
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
required: true,
|
||||
hasActiveToken: !!activeToken,
|
||||
hasValidProof: !!validProof,
|
||||
tokenExpiresAt: activeToken?.expiresAt ?? null,
|
||||
proofExpiresAt: validProof?.expiresAt ?? null,
|
||||
attemptsRemaining: activeToken
|
||||
? Math.max(0, activeToken.attemptLimit - activeToken.attempts)
|
||||
: null,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,176 @@
|
||||
import { DocumentStatus, EnvelopeType } from '@prisma/client';
|
||||
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||
import { DOCUMENT_AUDIT_LOG_TYPE } from '../../types/document-audit-logs';
|
||||
import { DocumentAuth } from '../../types/document-auth';
|
||||
import { createDocumentAuditLogData } from '../../utils/document-audit-logs';
|
||||
import { extractDocumentAuthMethods } from '../../utils/document-auth';
|
||||
import { generateSigningTwoFactorToken, generateTokenSalt, hashToken } from './token-utils';
|
||||
|
||||
const TOKEN_TTL_MINUTES = 10;
|
||||
const DEFAULT_ATTEMPT_LIMIT = 5;
|
||||
|
||||
export const SIGNING_2FA_REASON_CODES = {
|
||||
TWO_FA_NOT_REQUIRED: 'TWO_FA_NOT_REQUIRED',
|
||||
TWO_FA_RECIPIENT_INELIGIBLE: 'TWO_FA_RECIPIENT_INELIGIBLE',
|
||||
TWO_FA_ISSUER_FORBIDDEN: 'TWO_FA_ISSUER_FORBIDDEN',
|
||||
} as const;
|
||||
|
||||
export type IssueSigningTwoFactorTokenOptions = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
apiTokenId: number;
|
||||
};
|
||||
|
||||
export const issueSigningTwoFactorToken = async ({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
apiTokenId,
|
||||
}: IssueSigningTwoFactorTokenOptions) => {
|
||||
const envelope = await prisma.envelope.findFirst({
|
||||
where: {
|
||||
id: envelopeId,
|
||||
type: EnvelopeType.DOCUMENT,
|
||||
},
|
||||
include: {
|
||||
recipients: {
|
||||
where: {
|
||||
id: recipientId,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!envelope) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Envelope not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.status !== DocumentStatus.PENDING) {
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: `Document must be in PENDING status`,
|
||||
statusCode: 400,
|
||||
});
|
||||
}
|
||||
|
||||
if (envelope.recipients.length === 0) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found for this document',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const [recipient] = envelope.recipients;
|
||||
|
||||
const { derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: envelope.authOptions,
|
||||
recipientAuth: recipient.authOptions,
|
||||
});
|
||||
|
||||
const requiresExternal2FA = derivedRecipientActionAuth.includes(
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
);
|
||||
|
||||
if (!requiresExternal2FA) {
|
||||
await throwIssuanceDenied({
|
||||
envelopeId,
|
||||
recipient,
|
||||
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_NOT_REQUIRED,
|
||||
});
|
||||
}
|
||||
|
||||
if (recipient.signingStatus === 'SIGNED') {
|
||||
await throwIssuanceDenied({
|
||||
envelopeId,
|
||||
recipient,
|
||||
reasonCode: SIGNING_2FA_REASON_CODES.TWO_FA_RECIPIENT_INELIGIBLE,
|
||||
});
|
||||
}
|
||||
|
||||
const plaintextToken = generateSigningTwoFactorToken();
|
||||
const salt = generateTokenSalt();
|
||||
const tokenHash = hashToken(plaintextToken, salt);
|
||||
const expiresAt = new Date(Date.now() + TOKEN_TTL_MINUTES * 60 * 1000);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.signingTwoFactorToken.updateMany({
|
||||
where: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
data: {
|
||||
status: 'REVOKED',
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
const newToken = await tx.signingTwoFactorToken.create({
|
||||
data: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
tokenHash,
|
||||
tokenSalt: salt,
|
||||
expiresAt,
|
||||
attemptLimit: DEFAULT_ATTEMPT_LIMIT,
|
||||
issuedByApiTokenId: apiTokenId,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId: newToken.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return newToken;
|
||||
});
|
||||
|
||||
return {
|
||||
token: plaintextToken,
|
||||
tokenId: result.id,
|
||||
expiresAt: result.expiresAt,
|
||||
ttlSeconds: TOKEN_TTL_MINUTES * 60,
|
||||
attemptLimit: result.attemptLimit,
|
||||
issuedAt: result.createdAt,
|
||||
};
|
||||
};
|
||||
|
||||
const throwIssuanceDenied = async ({
|
||||
envelopeId,
|
||||
recipient,
|
||||
reasonCode,
|
||||
}: {
|
||||
envelopeId: string;
|
||||
recipient: { id: number; email: string; name: string | null };
|
||||
reasonCode: string;
|
||||
}) => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name ?? '',
|
||||
reasonCode,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
throw new AppError(AppErrorCode.INVALID_REQUEST, {
|
||||
message: reasonCode,
|
||||
statusCode: 400,
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import crypto from 'crypto';
|
||||
|
||||
const TOKEN_LENGTH = 6;
|
||||
const SALT_LENGTH = 32;
|
||||
const HASH_ITERATIONS = 100000;
|
||||
const HASH_KEY_LENGTH = 64;
|
||||
const HASH_DIGEST = 'sha512';
|
||||
|
||||
export const generateSigningTwoFactorToken = (): string => {
|
||||
const bytes = crypto.randomBytes(4);
|
||||
const num = bytes.readUInt32BE(0) % 10 ** TOKEN_LENGTH;
|
||||
|
||||
return num.toString().padStart(TOKEN_LENGTH, '0');
|
||||
};
|
||||
|
||||
export const generateTokenSalt = (): string => {
|
||||
return crypto.randomBytes(SALT_LENGTH).toString('hex');
|
||||
};
|
||||
|
||||
export const hashToken = (token: string, salt: string): string => {
|
||||
return crypto
|
||||
.pbkdf2Sync(token, salt, HASH_ITERATIONS, HASH_KEY_LENGTH, HASH_DIGEST)
|
||||
.toString('hex');
|
||||
};
|
||||
|
||||
export const verifyTokenHash = (token: string, salt: string, expectedHash: string): boolean => {
|
||||
const hash = hashToken(token, salt);
|
||||
|
||||
return crypto.timingSafeEqual(Buffer.from(hash, 'hex'), Buffer.from(expectedHash, 'hex'));
|
||||
};
|
||||
@@ -0,0 +1,251 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
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 { verifyTokenHash } from './token-utils';
|
||||
|
||||
const PROOF_TTL_MINUTES = 10;
|
||||
|
||||
export const SIGNING_2FA_VERIFY_REASON_CODES = {
|
||||
TWO_FA_TOKEN_INVALID: 'TWO_FA_TOKEN_INVALID',
|
||||
TWO_FA_TOKEN_EXPIRED: 'TWO_FA_TOKEN_EXPIRED',
|
||||
TWO_FA_TOKEN_REVOKED: 'TWO_FA_TOKEN_REVOKED',
|
||||
TWO_FA_TOKEN_CONSUMED: 'TWO_FA_TOKEN_CONSUMED',
|
||||
TWO_FA_ATTEMPT_LIMIT_REACHED: 'TWO_FA_ATTEMPT_LIMIT_REACHED',
|
||||
TWO_FA_NOT_ISSUED: 'TWO_FA_NOT_ISSUED',
|
||||
} as const;
|
||||
|
||||
export type VerifySigningTwoFactorTokenOptions = {
|
||||
recipientId: number;
|
||||
envelopeId: string;
|
||||
token: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export const verifySigningTwoFactorToken = async ({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
token: plaintextToken,
|
||||
sessionId,
|
||||
}: VerifySigningTwoFactorTokenOptions) => {
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
id: recipientId,
|
||||
envelopeId,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
name: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const activeToken = await prisma.signingTwoFactorToken.findFirst({
|
||||
where: {
|
||||
recipientId,
|
||||
envelopeId,
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
},
|
||||
});
|
||||
|
||||
if (!activeToken) {
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: 'none',
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED,
|
||||
attemptsUsed: 0,
|
||||
attemptLimit: 0,
|
||||
errorCode: AppErrorCode.INVALID_REQUEST,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToken.expiresAt < new Date()) {
|
||||
await prisma.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
status: 'EXPIRED',
|
||||
},
|
||||
});
|
||||
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: activeToken.id,
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED,
|
||||
attemptsUsed: activeToken.attempts,
|
||||
attemptLimit: activeToken.attemptLimit,
|
||||
errorCode: AppErrorCode.EXPIRED_CODE,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (activeToken.attempts >= activeToken.attemptLimit) {
|
||||
await prisma.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
status: 'REVOKED',
|
||||
revokedAt: new Date(),
|
||||
},
|
||||
});
|
||||
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: activeToken.id,
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED,
|
||||
attemptsUsed: activeToken.attempts,
|
||||
attemptLimit: activeToken.attemptLimit,
|
||||
errorCode: AppErrorCode.TOO_MANY_REQUESTS,
|
||||
statusCode: 429,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const isValid = verifyTokenHash(plaintextToken, activeToken.tokenSalt, activeToken.tokenHash);
|
||||
|
||||
if (!isValid) {
|
||||
const updatedToken = await prisma.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
await throwVerificationError({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId: activeToken.id,
|
||||
reasonCode: SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_INVALID,
|
||||
attemptsUsed: updatedToken.attempts,
|
||||
attemptLimit: updatedToken.attemptLimit,
|
||||
errorCode: AppErrorCode.INVALID_REQUEST,
|
||||
statusCode: 400,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const proofExpiresAt = new Date(Date.now() + PROOF_TTL_MINUTES * 60 * 1000);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
await tx.signingTwoFactorToken.update({
|
||||
where: { id: activeToken.id },
|
||||
data: {
|
||||
status: 'CONSUMED',
|
||||
consumedAt: new Date(),
|
||||
attempts: { increment: 1 },
|
||||
},
|
||||
});
|
||||
|
||||
const proof = await tx.signingSessionTwoFactorProof.upsert({
|
||||
where: {
|
||||
sessionId_recipientId_envelopeId: {
|
||||
sessionId,
|
||||
recipientId,
|
||||
envelopeId,
|
||||
},
|
||||
},
|
||||
create: {
|
||||
sessionId,
|
||||
recipientId,
|
||||
envelopeId,
|
||||
expiresAt: proofExpiresAt,
|
||||
},
|
||||
update: {
|
||||
verifiedAt: new Date(),
|
||||
expiresAt: proofExpiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId: activeToken.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
await tx.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId: activeToken.id,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
return proof;
|
||||
});
|
||||
|
||||
return {
|
||||
verified: true,
|
||||
proofId: result.id,
|
||||
expiresAt: result.expiresAt,
|
||||
};
|
||||
};
|
||||
|
||||
type ThrowVerificationErrorOptions = {
|
||||
envelopeId: string;
|
||||
recipient: { id: number; email: string; name: string };
|
||||
tokenId: string;
|
||||
reasonCode: string;
|
||||
attemptsUsed: number;
|
||||
attemptLimit: number;
|
||||
errorCode: AppErrorCode;
|
||||
statusCode: number;
|
||||
};
|
||||
|
||||
const throwVerificationError = async ({
|
||||
envelopeId,
|
||||
recipient,
|
||||
tokenId,
|
||||
reasonCode,
|
||||
attemptsUsed,
|
||||
attemptLimit,
|
||||
errorCode,
|
||||
statusCode,
|
||||
}: ThrowVerificationErrorOptions): Promise<never> => {
|
||||
await prisma.documentAuditLog.create({
|
||||
data: createDocumentAuditLogData({
|
||||
type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED,
|
||||
envelopeId,
|
||||
data: {
|
||||
recipientId: recipient.id,
|
||||
recipientEmail: recipient.email,
|
||||
recipientName: recipient.name,
|
||||
tokenId,
|
||||
reasonCode,
|
||||
attemptsUsed,
|
||||
attemptLimit,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
throw new AppError(errorCode, {
|
||||
message: reasonCode,
|
||||
statusCode,
|
||||
});
|
||||
};
|
||||
@@ -50,6 +50,14 @@ export const ZDocumentAuditLogTypeSchema = z.enum([
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_REQUESTED', // When ACCESS AUTH 2FA is requested.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_VALIDATED', // When ACCESS AUTH 2FA is successfully validated.
|
||||
'DOCUMENT_ACCESS_AUTH_2FA_FAILED', // When ACCESS AUTH 2FA validation fails.
|
||||
|
||||
// External signing 2FA events.
|
||||
'EXTERNAL_2FA_TOKEN_ISSUED',
|
||||
'EXTERNAL_2FA_TOKEN_ISSUE_DENIED',
|
||||
'EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED',
|
||||
'EXTERNAL_2FA_TOKEN_VERIFY_FAILED',
|
||||
'EXTERNAL_2FA_TOKEN_CONSUMED',
|
||||
'EXTERNAL_2FA_TOKEN_REVOKED',
|
||||
]);
|
||||
|
||||
export const ZDocumentAuditLogEmailTypeSchema = z.enum([
|
||||
@@ -694,6 +702,59 @@ export const ZDocumentAuditLogEventDocumentDelegatedOwnerCreatedSchema = z.objec
|
||||
}),
|
||||
});
|
||||
|
||||
const ZExternal2FARecipientDataSchema = z.object({
|
||||
recipientId: z.number(),
|
||||
recipientEmail: z.string(),
|
||||
recipientName: z.string(),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenIssuedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
reasonCode: z.string().optional(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
reasonCode: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
reasonCode: z.string(),
|
||||
attemptsUsed: z.number(),
|
||||
attemptLimit: z.number(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenConsumedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogEventExternal2FATokenRevokedSchema = z.object({
|
||||
type: z.literal(DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED),
|
||||
data: ZExternal2FARecipientDataSchema.extend({
|
||||
tokenId: z.string(),
|
||||
reasonCode: z.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const ZDocumentAuditLogBaseSchema = z.object({
|
||||
id: z.string(),
|
||||
createdAt: z.date(),
|
||||
@@ -739,6 +800,12 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export const ZDocumentAuthTypesSchema = z.enum([
|
||||
'ACCOUNT',
|
||||
'PASSKEY',
|
||||
'TWO_FACTOR_AUTH',
|
||||
'EXTERNAL_TWO_FACTOR_AUTH',
|
||||
'PASSWORD',
|
||||
'EXPLICIT_NONE',
|
||||
]);
|
||||
@@ -40,6 +41,10 @@ const ZDocumentAuth2FASchema = z.object({
|
||||
method: z.enum(['email', 'authenticator']).default('authenticator').optional(),
|
||||
});
|
||||
|
||||
const ZDocumentAuthExternal2FASchema = z.object({
|
||||
type: z.literal(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH),
|
||||
});
|
||||
|
||||
/**
|
||||
* All the document auth methods for both accessing and actioning.
|
||||
*/
|
||||
@@ -48,6 +53,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
]);
|
||||
|
||||
@@ -73,6 +79,7 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
]);
|
||||
export const ZDocumentActionAuthTypesSchema = z
|
||||
@@ -80,6 +87,7 @@ export const ZDocumentActionAuthTypesSchema = z
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
])
|
||||
.describe(
|
||||
@@ -108,6 +116,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
]);
|
||||
@@ -116,6 +125,7 @@ export const ZRecipientActionAuthTypesSchema = z
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
DocumentAuth.EXPLICIT_NONE,
|
||||
])
|
||||
|
||||
@@ -32,6 +32,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
authenticationPortal: z.boolean().optional(),
|
||||
|
||||
allowLegacyEnvelopes: z.boolean().optional(),
|
||||
|
||||
externalSigning2fa: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export type TClaimFlags = z.infer<typeof ZClaimFlagsSchema>;
|
||||
@@ -94,6 +96,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'allowLegacyEnvelopes',
|
||||
label: 'Allow Legacy Envelopes',
|
||||
},
|
||||
externalSigning2fa: {
|
||||
key: 'externalSigning2fa',
|
||||
label: 'External signing 2FA',
|
||||
isEnterprise: true,
|
||||
},
|
||||
};
|
||||
|
||||
export enum INTERNAL_CLAIM_ID {
|
||||
|
||||
@@ -582,6 +582,48 @@ export const formatDocumentAuditLogAction = (
|
||||
user: message,
|
||||
};
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token issued for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_ISSUE_DENIED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token issuance denied for recipient ${data.recipientEmail}: ${data.reasonCode}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_SUCCEEDED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA verification succeeded for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_VERIFY_FAILED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA verification failed for recipient ${data.recipientEmail}: ${data.reasonCode} (attempt ${data.attemptsUsed}/${data.attemptLimit})`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_CONSUMED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token consumed for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.with({ type: DOCUMENT_AUDIT_LOG_TYPE.EXTERNAL_2FA_TOKEN_REVOKED }, ({ data }) => {
|
||||
const message = msg({
|
||||
message: `External 2FA token revoked for recipient ${data.recipientEmail}`,
|
||||
context: `Audit log format`,
|
||||
});
|
||||
return { anonymous: message, you: message, user: message };
|
||||
})
|
||||
.exhaustive();
|
||||
|
||||
let selectedDescription = description.anonymous;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "SigningTwoFactorTokenStatus" AS ENUM ('ACTIVE', 'CONSUMED', 'REVOKED', 'EXPIRED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SigningTwoFactorToken" (
|
||||
"id" TEXT NOT NULL,
|
||||
"recipientId" INTEGER NOT NULL,
|
||||
"envelopeId" TEXT NOT NULL,
|
||||
"tokenHash" TEXT NOT NULL,
|
||||
"tokenSalt" TEXT NOT NULL,
|
||||
"status" "SigningTwoFactorTokenStatus" NOT NULL DEFAULT 'ACTIVE',
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
"consumedAt" TIMESTAMP(3),
|
||||
"revokedAt" TIMESTAMP(3),
|
||||
"attempts" INTEGER NOT NULL DEFAULT 0,
|
||||
"attemptLimit" INTEGER NOT NULL DEFAULT 5,
|
||||
"issuedByApiTokenId" INTEGER,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "SigningTwoFactorToken_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "SigningSessionTwoFactorProof" (
|
||||
"id" TEXT NOT NULL,
|
||||
"sessionId" TEXT NOT NULL,
|
||||
"recipientId" INTEGER NOT NULL,
|
||||
"envelopeId" TEXT NOT NULL,
|
||||
"verifiedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"expiresAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "SigningSessionTwoFactorProof_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningTwoFactorToken_recipientId_envelopeId_status_idx" ON "SigningTwoFactorToken"("recipientId", "envelopeId", "status");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningTwoFactorToken_envelopeId_idx" ON "SigningTwoFactorToken"("envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningSessionTwoFactorProof_recipientId_envelopeId_idx" ON "SigningSessionTwoFactorProof"("recipientId", "envelopeId");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "SigningSessionTwoFactorProof_expiresAt_idx" ON "SigningSessionTwoFactorProof"("expiresAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "SigningSessionTwoFactorProof_sessionId_recipientId_envelope_key" ON "SigningSessionTwoFactorProof"("sessionId", "recipientId", "envelopeId");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningTwoFactorToken" ADD CONSTRAINT "SigningTwoFactorToken_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_recipientId_fkey" FOREIGN KEY ("recipientId") REFERENCES "Recipient"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "SigningSessionTwoFactorProof" ADD CONSTRAINT "SigningSessionTwoFactorProof_envelopeId_fkey" FOREIGN KEY ("envelopeId") REFERENCES "Envelope"("id") ON DELETE CASCADE ON UPDATE CASCADE;
|
||||
@@ -430,6 +430,9 @@ model Envelope {
|
||||
|
||||
envelopeAttachments EnvelopeAttachment[]
|
||||
|
||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
||||
|
||||
@@index([type])
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
@@ -588,6 +591,9 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
||||
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([envelopeId])
|
||||
@@ -1076,3 +1082,55 @@ model Counter {
|
||||
id String @id
|
||||
value Int
|
||||
}
|
||||
|
||||
enum SigningTwoFactorTokenStatus {
|
||||
ACTIVE
|
||||
CONSUMED
|
||||
REVOKED
|
||||
EXPIRED
|
||||
}
|
||||
|
||||
model SigningTwoFactorToken {
|
||||
id String @id @default(cuid())
|
||||
|
||||
recipientId Int
|
||||
envelopeId String
|
||||
|
||||
tokenHash String
|
||||
tokenSalt String
|
||||
|
||||
status SigningTwoFactorTokenStatus @default(ACTIVE)
|
||||
expiresAt DateTime
|
||||
consumedAt DateTime?
|
||||
revokedAt DateTime?
|
||||
attempts Int @default(0)
|
||||
attemptLimit Int @default(5)
|
||||
|
||||
issuedByApiTokenId Int?
|
||||
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([recipientId, envelopeId, status])
|
||||
@@index([envelopeId])
|
||||
}
|
||||
|
||||
model SigningSessionTwoFactorProof {
|
||||
id String @id @default(cuid())
|
||||
|
||||
sessionId String
|
||||
recipientId Int
|
||||
envelopeId String
|
||||
|
||||
verifiedAt DateTime @default(now())
|
||||
expiresAt DateTime
|
||||
|
||||
recipient Recipient @relation(fields: [recipientId], references: [id], onDelete: Cascade)
|
||||
envelope Envelope @relation(fields: [envelopeId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@unique([sessionId, recipientId, envelopeId])
|
||||
@@index([recipientId, envelopeId])
|
||||
@@index([expiresAt])
|
||||
}
|
||||
|
||||
@@ -30,6 +30,9 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope';
|
||||
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
||||
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
||||
import { signEnvelopeFieldRoute } from './sign-envelope-field';
|
||||
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
|
||||
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
|
||||
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
|
||||
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
|
||||
import { updateEnvelopeRoute } from './update-envelope';
|
||||
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
||||
@@ -87,5 +90,10 @@ export const envelopeRouter = router({
|
||||
duplicate: duplicateEnvelopeRoute,
|
||||
distribute: distributeEnvelopeRoute,
|
||||
redistribute: redistributeEnvelopeRoute,
|
||||
signing2fa: {
|
||||
issue: issueSigningTwoFactorTokenRoute,
|
||||
verify: verifySigningTwoFactorTokenRoute,
|
||||
getStatus: getSigningTwoFactorStatusRoute,
|
||||
},
|
||||
signingStatus: signingStatusEnvelopeRoute,
|
||||
});
|
||||
|
||||
@@ -182,6 +182,7 @@ export const signEnvelopeFieldRoute = procedure
|
||||
field,
|
||||
userId: user?.id,
|
||||
authOptions,
|
||||
recipientToken: token,
|
||||
});
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZGetSigningTwoFactorStatusRequestSchema,
|
||||
ZGetSigningTwoFactorStatusResponseSchema,
|
||||
} from './get-signing-two-factor-status.types';
|
||||
|
||||
export const getSigningTwoFactorStatusRoute = procedure
|
||||
.input(ZGetSigningTwoFactorStatusRequestSchema)
|
||||
.output(ZGetSigningTwoFactorStatusResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { token } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
token: '***',
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return await getSigningTwoFactorStatus({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
sessionId: token,
|
||||
});
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetSigningTwoFactorStatusRequestSchema = z.object({
|
||||
token: z.string().describe('The recipient signing token from the signing URL.'),
|
||||
});
|
||||
|
||||
export const ZGetSigningTwoFactorStatusResponseSchema = z.object({
|
||||
required: z.boolean().describe('Whether external 2FA is required for this recipient.'),
|
||||
hasActiveToken: z.boolean().describe('Whether an active (unexpired) token exists.'),
|
||||
hasValidProof: z.boolean().describe('Whether a valid session proof exists.'),
|
||||
tokenExpiresAt: z.date().nullable().describe('When the active token expires, if any.'),
|
||||
proofExpiresAt: z.date().nullable().describe('When the session proof expires, if any.'),
|
||||
attemptsRemaining: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Remaining verification attempts for the active token.'),
|
||||
});
|
||||
|
||||
export type TGetSigningTwoFactorStatusRequest = z.infer<
|
||||
typeof ZGetSigningTwoFactorStatusRequestSchema
|
||||
>;
|
||||
export type TGetSigningTwoFactorStatusResponse = z.infer<
|
||||
typeof ZGetSigningTwoFactorStatusResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZIssueSigningTwoFactorTokenRequestSchema,
|
||||
ZIssueSigningTwoFactorTokenResponseSchema,
|
||||
issueSigningTwoFactorTokenMeta,
|
||||
} from './issue-signing-two-factor-token.types';
|
||||
|
||||
export const issueSigningTwoFactorTokenRoute = authenticatedProcedure
|
||||
.meta(issueSigningTwoFactorTokenMeta)
|
||||
.input(ZIssueSigningTwoFactorTokenRequestSchema)
|
||||
.output(ZIssueSigningTwoFactorTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { envelopeId, recipientId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
const authorizationHeader = ctx.req.headers.get('authorization');
|
||||
|
||||
if (!authorizationHeader) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token required to issue signing 2FA tokens',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token required to issue signing 2FA tokens',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
const result = await issueSigningTwoFactorToken({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const issueSigningTwoFactorTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/signing-2fa/issue',
|
||||
summary: 'Issue a signing 2FA token',
|
||||
description:
|
||||
'Issue a one-time signing two-factor authentication token for a recipient. The caller is responsible for delivering the token to the signer through their own channel (e.g., SMS).',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZIssueSigningTwoFactorTokenRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope.'),
|
||||
recipientId: z.number().describe('The ID of the recipient to issue the token for.'),
|
||||
});
|
||||
|
||||
export const ZIssueSigningTwoFactorTokenResponseSchema = z.object({
|
||||
token: z.string().describe('The plaintext one-time token. Visible exactly once.'),
|
||||
tokenId: z.string().describe('The ID of the created token record.'),
|
||||
expiresAt: z.date().describe('When the token expires.'),
|
||||
ttlSeconds: z.number().describe('Token time-to-live in seconds.'),
|
||||
attemptLimit: z.number().describe('Maximum verification attempts allowed.'),
|
||||
issuedAt: z.date().describe('When the token was issued.'),
|
||||
});
|
||||
|
||||
export type TIssueSigningTwoFactorTokenRequest = z.infer<
|
||||
typeof ZIssueSigningTwoFactorTokenRequestSchema
|
||||
>;
|
||||
export type TIssueSigningTwoFactorTokenResponse = z.infer<
|
||||
typeof ZIssueSigningTwoFactorTokenResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifySigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZVerifySigningTwoFactorTokenRequestSchema,
|
||||
ZVerifySigningTwoFactorTokenResponseSchema,
|
||||
} from './verify-signing-two-factor-token.types';
|
||||
|
||||
export const verifySigningTwoFactorTokenRoute = procedure
|
||||
.input(ZVerifySigningTwoFactorTokenRequestSchema)
|
||||
.output(ZVerifySigningTwoFactorTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, code } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
token: '***',
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await verifySigningTwoFactorToken({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
token: code,
|
||||
sessionId: token,
|
||||
});
|
||||
|
||||
return {
|
||||
verified: result!.verified,
|
||||
expiresAt: result!.expiresAt,
|
||||
};
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZVerifySigningTwoFactorTokenRequestSchema = z.object({
|
||||
token: z.string().describe('The recipient signing token from the signing URL.'),
|
||||
code: z
|
||||
.string()
|
||||
.min(6)
|
||||
.max(6)
|
||||
.regex(/^\d{6}$/)
|
||||
.describe('The 6-digit one-time code to verify.'),
|
||||
});
|
||||
|
||||
export const ZVerifySigningTwoFactorTokenResponseSchema = z.object({
|
||||
verified: z.boolean().describe('Whether the code was successfully verified.'),
|
||||
expiresAt: z.date().describe('When the session proof expires.'),
|
||||
});
|
||||
|
||||
export type TVerifySigningTwoFactorTokenRequest = z.infer<
|
||||
typeof ZVerifySigningTwoFactorTokenRequestSchema
|
||||
>;
|
||||
export type TVerifySigningTwoFactorTokenResponse = z.infer<
|
||||
typeof ZVerifySigningTwoFactorTokenResponseSchema
|
||||
>;
|
||||
Reference in New Issue
Block a user