mirror of
https://github.com/documenso/documenso.git
synced 2026-07-27 02:15:05 +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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user