mirror of
https://github.com/documenso/documenso.git
synced 2026-06-22 12:22:14 +10:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8ca8ad907e | |||
| f7b3554b2a | |||
| 6ff8cd7cb2 | |||
| 138d663c25 | |||
| 9194884fbe | |||
| 9de87ca906 | |||
| 7163800d36 | |||
| bd56929db1 |
@@ -0,0 +1,289 @@
|
||||
---
|
||||
date: 2026-02-02
|
||||
title: Support For External 2fa Codes
|
||||
---
|
||||
|
||||
## Objective
|
||||
|
||||
Enable organizations to enforce a second factor for document signing while keeping delivery fully external (for example customer-owned SMS), with strong recipient/session binding and auditable controls.
|
||||
|
||||
## Problem Context
|
||||
|
||||
- Many legacy organizations still rely on SMS for second-factor delivery.
|
||||
- Their users cannot realistically migrate to authenticator apps or passkeys yet.
|
||||
- Operating first-party SMS infrastructure in Documenso is costly, risky, and outside core scope.
|
||||
- Customers need an API-first integration path that fits existing notification infrastructure and compliance controls.
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Introduce external 2FA codes for signing:
|
||||
|
||||
1. A trusted backend service requests a one-time signing token via API.
|
||||
2. The customer delivers that token to the signer through their own existing channel (for example SMS).
|
||||
3. The signer enters the token in the signing flow.
|
||||
4. Documenso validates the submitted token, then issues a short-lived session-bound verification proof.
|
||||
5. Signature completion is allowed only when the proof is present and valid for that recipient signing session.
|
||||
|
||||
## Decisions Captured In Interview
|
||||
|
||||
- Enforcement scope: template-level default with per-recipient override.
|
||||
- Issuer trust boundary: scoped machine API keys with explicit permission.
|
||||
- Token lifecycle: newest token immediately revokes prior active token for same recipient/document.
|
||||
- Brute-force control: token-scoped hard attempt cap.
|
||||
- Security defaults: TTL 10 minutes, max 5 attempts.
|
||||
- Verification unlock: session-bound proof (not global recipient unlock).
|
||||
- Issuance contract: idempotent-ish reissue behavior with explicit structured denial reasons.
|
||||
- Audit privacy: never log token/code material; log identifiers and reason codes only.
|
||||
- Missing token at signing time: block with actionable state.
|
||||
- Rollback behavior: feature-flag off for new sessions only.
|
||||
- Resend/recovery in v1: support-owned reissue guidance only (no signer self-serve trigger).
|
||||
- Workspace policy controls in v1: no per-workspace TTL/attempt overrides.
|
||||
- Session proof TTL in v1: 10 minutes.
|
||||
|
||||
## Scope
|
||||
|
||||
### In Scope
|
||||
|
||||
- API endpoint to issue short-lived signing 2FA tokens for eligible recipients.
|
||||
- Secure storage/verification mechanism (hashed token + expiry + attempt tracking).
|
||||
- Signing UI step to collect token before signature submission.
|
||||
- Standard operating flow: token is generated via API and entered by the recipient in the UI.
|
||||
- Verification endpoint/path integrated into signing completion checks.
|
||||
- Audit logging for token issuance and verification attempts.
|
||||
- Template policy defaults with per-recipient override support.
|
||||
- Session-bound verification proof issuance after successful code validation.
|
||||
- Feature-flagged rollout controls at workspace/organization scope.
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- Native SMS sending/providers inside Documenso.
|
||||
- New authenticator/passkey implementation.
|
||||
- Cross-channel delivery guarantees (owned by customer infrastructure).
|
||||
- UI-only token generation as the primary flow in this phase.
|
||||
- Fully configurable TTL/attempt policy per workspace in v1.
|
||||
- Customer callback/webhook resend orchestration in v1.
|
||||
- Signer-triggered self-serve reissue controls in v1.
|
||||
|
||||
## Functional Requirements
|
||||
|
||||
- Token is recipient-bound and document/session-bound.
|
||||
- Token cannot be shared across recipients or recipient roles.
|
||||
- A recipient token only authorizes signature actions for that same recipient identity.
|
||||
- If the same human is represented by multiple recipient records, each recipient record still requires its own token.
|
||||
- Token has strict TTL of 10 minutes and single-use semantics.
|
||||
- Token verification fails on expiry, mismatch, too many attempts, or reuse.
|
||||
- Endpoint access is restricted to scoped API clients with explicit issuance permission.
|
||||
- Clear, localized user errors for invalid/expired tokens.
|
||||
- Max 5 verification attempts per token; on cap reached, token becomes unusable and signer must use a newly issued token.
|
||||
- Issuing a new token revokes any existing active token for the same recipient/document pair.
|
||||
- Successful verification creates a short-lived session-bound proof; only that session can complete signature.
|
||||
- If 2FA is required but no valid token has been issued yet, signing must be blocked with actionable guidance.
|
||||
|
||||
## Non-Functional Requirements
|
||||
|
||||
- Verification and consumption path must be atomic and race-safe under concurrent requests.
|
||||
- Error responses must use stable machine-readable reason codes for customer integrations.
|
||||
- p95 verification latency should remain within existing signing guardrail budget (target: <= 300 ms server-side).
|
||||
- Security controls and audit logging must not expose token/code values in logs, traces, or analytics payloads.
|
||||
|
||||
## Policy Model
|
||||
|
||||
- Default requirement is configured at template/workflow level.
|
||||
- Sender can override requirement per recipient before send.
|
||||
- Effective policy is materialized on recipient/document at send time to avoid template drift during in-flight signing.
|
||||
- Feature flag gates enforcement by workspace/organization for rollout and rollback.
|
||||
|
||||
## API Contract
|
||||
|
||||
### Token Issuance Endpoint
|
||||
|
||||
- Auth: scoped API key with dedicated permission (for example `signing_2fa:issue`).
|
||||
- Input: recipient/document context and optional idempotency metadata.
|
||||
- Behavior:
|
||||
- Eligible recipient: always issues a fresh token and revokes prior active token.
|
||||
- Ineligible/forbidden state: returns structured 4xx with explicit reason code.
|
||||
- Never returns previously generated plaintext token; token is visible exactly once at issuance.
|
||||
- Output:
|
||||
- Plaintext token (single response only).
|
||||
- Metadata for integration handling (expiresAt, ttlSeconds, attemptLimit, issuedAt).
|
||||
|
||||
### Verification Endpoint
|
||||
|
||||
- Input: token submission from signing UI bound to current signing session context.
|
||||
- Behavior:
|
||||
- Valid token: atomically consumes token and issues session-bound verification proof.
|
||||
- Invalid token: increments attempts and returns reason code.
|
||||
- Expired/revoked/consumed/capped: returns denial reason without revealing sensitive internals.
|
||||
- Output:
|
||||
- Success: verification state for current session.
|
||||
- Failure: localized user-safe message + machine reason code.
|
||||
|
||||
### Resend/Reissue Behavior (v1)
|
||||
|
||||
- No signer-triggered callback/webhook or self-serve reissue endpoint in v1.
|
||||
- If token is missing/expired/revoked/capped, signer sees actionable guidance to contact sender/support.
|
||||
- Reissue remains an API-key-initiated operation from trusted customer backend only.
|
||||
|
||||
### Suggested Reason Codes
|
||||
|
||||
- `TWO_FA_NOT_REQUIRED`
|
||||
- `TWO_FA_NOT_ISSUED`
|
||||
- `TWO_FA_TOKEN_INVALID`
|
||||
- `TWO_FA_TOKEN_EXPIRED`
|
||||
- `TWO_FA_TOKEN_REVOKED`
|
||||
- `TWO_FA_TOKEN_CONSUMED`
|
||||
- `TWO_FA_ATTEMPT_LIMIT_REACHED`
|
||||
- `TWO_FA_ISSUER_FORBIDDEN`
|
||||
- `TWO_FA_RECIPIENT_INELIGIBLE`
|
||||
|
||||
## Data Model
|
||||
|
||||
Create `signing_two_factor_tokens` (name indicative):
|
||||
|
||||
- `id`
|
||||
- `recipientId`
|
||||
- `documentId`
|
||||
- `tokenHash`
|
||||
- `tokenSalt` (or use KDF settings sufficient to avoid raw-secret recovery)
|
||||
- `expiresAt`
|
||||
- `consumedAt` nullable
|
||||
- `revokedAt` nullable
|
||||
- `attempts` default 0
|
||||
- `attemptLimit` default 5
|
||||
- `issuedByApiKeyId` (or actor reference)
|
||||
- `createdAt`
|
||||
|
||||
Optional companion table/entity for session proof:
|
||||
|
||||
- `signing_session_2fa_proofs`
|
||||
- `sessionId`
|
||||
- `recipientId`
|
||||
- `documentId`
|
||||
- `verifiedAt`
|
||||
- `expiresAt`
|
||||
|
||||
Constraints and indexes:
|
||||
|
||||
- Index on (`recipientId`, `documentId`, `expiresAt`).
|
||||
- At most one active token per (`recipientId`, `documentId`) enforced by transactional revoke-on-issue.
|
||||
- Guard against lost-update on attempts and consume via row lock or atomic update conditions.
|
||||
|
||||
## Signing UX
|
||||
|
||||
- Insert 2FA code step before signature commit when effective policy requires it.
|
||||
- UX states:
|
||||
- Waiting for code input.
|
||||
- Invalid code (remaining attempts shown where safe).
|
||||
- Expired/revoked/attempt cap reached with clear next-step copy.
|
||||
- Not issued yet state with actionable guidance.
|
||||
- Recovery copy in v1 must direct signer to sender/support (no in-product resend action).
|
||||
- Localization required for all user-facing errors.
|
||||
- Accessibility: input labeling, error announcement, keyboard submission, mobile-friendly numeric entry.
|
||||
- Session-bound proof behavior must be transparent to user (no global unlock across devices/tabs).
|
||||
|
||||
## Security Requirements
|
||||
|
||||
- Never persist plaintext token; store salted hash only.
|
||||
- Rate-limit issuance and verification attempts.
|
||||
- Invalidate previous active token immediately when a new token is issued.
|
||||
- Emit security/audit events with actor, recipient, document, timestamp, and reason codes.
|
||||
- Prevent token leakage in logs, telemetry, and error payloads.
|
||||
- Use constant-time comparison and hardened random token generation.
|
||||
- Enforce short proof lifetime for verified session to reduce replay window.
|
||||
- Set proof TTL to 10 minutes in v1.
|
||||
|
||||
## Observability And Audit
|
||||
|
||||
Emit events for:
|
||||
|
||||
- `2fa_token_issued`
|
||||
- `2fa_token_issue_denied`
|
||||
- `2fa_token_verify_succeeded`
|
||||
- `2fa_token_verify_failed`
|
||||
- `2fa_token_consumed`
|
||||
- `2fa_token_revoked`
|
||||
|
||||
Event fields:
|
||||
|
||||
- `workspaceId`, `documentId`, `recipientId`
|
||||
- `actorType` (api_key, signer_session, system)
|
||||
- `actorId` (where applicable)
|
||||
- `reasonCode`
|
||||
- `ipHash`, `userAgentHash` (if available)
|
||||
- `timestamp`
|
||||
|
||||
Metrics and alerts:
|
||||
|
||||
- Issuance success/failure rates.
|
||||
- Verification success/failure rate split by reason code.
|
||||
- Attempt-limit-hit rate.
|
||||
- p95 verification latency.
|
||||
- Alert on unusual spikes in invalid attempts per recipient/document/workspace.
|
||||
|
||||
## Implementation Plan
|
||||
|
||||
1. Domain model
|
||||
- Add signing 2FA token entity/table and session-proof persistence.
|
||||
2. Token issuance API
|
||||
- Add authenticated route for scoped API keys; issue fresh token, revoke prior active.
|
||||
3. Verification logic
|
||||
- Validate token state, increment attempts atomically, consume on success, mint session proof.
|
||||
4. Signing flow integration
|
||||
- Add UI token prompt and backend guard requiring valid session proof.
|
||||
5. Observability
|
||||
- Add reason-coded events and dashboards/alerts.
|
||||
6. Controls
|
||||
- Add rate limits, attempt cap (5), revoke-on-reissue, and feature flag checks.
|
||||
7. Testing
|
||||
- Unit tests for generation/verification edge cases.
|
||||
- Integration tests for API and signing flow.
|
||||
- Concurrency tests for double-submit and parallel verification.
|
||||
|
||||
## Testing Matrix
|
||||
|
||||
- Token issuance for eligible/ineligible recipients.
|
||||
- Reissue revokes previous token immediately.
|
||||
- Verification success path creates session-bound proof.
|
||||
- Verification fails on mismatch, expiry, revoked, consumed, cap reached.
|
||||
- Attempt counter increments correctly under concurrent requests.
|
||||
- Signature blocked when proof absent or expired.
|
||||
- Recipient A token rejected for recipient B (including same human/multiple recipient records).
|
||||
- Feature flag off: new sessions bypass external 2FA requirement.
|
||||
- Audit events emitted with expected reason codes and no token material.
|
||||
|
||||
## Acceptance Criteria
|
||||
|
||||
- External system can request a token for an eligible signer through API.
|
||||
- Signer cannot complete signing without valid token when policy requires 2FA.
|
||||
- A token issued for recipient A is always rejected for recipient B, including when both recipients map to the same underlying person.
|
||||
- Valid token allows signing exactly once within TTL.
|
||||
- Expired/reused/invalid tokens are rejected with clear errors.
|
||||
- No Documenso-owned SMS infrastructure is introduced.
|
||||
- Audit trail captures issuance and verification outcomes.
|
||||
- Default policy can be set at template level with per-recipient override at send time.
|
||||
- New token issuance revokes prior active token for same recipient/document.
|
||||
- Max 5 failed attempts per token is enforced.
|
||||
- Successful verification unlocks only the active signing session.
|
||||
- If no token has been issued yet, signer is blocked with actionable guidance.
|
||||
|
||||
## Rollout Strategy
|
||||
|
||||
- Ship behind feature flag (workspace-level or organization-level).
|
||||
- Enable first for pilot customers in regulated domains.
|
||||
- Monitor verification failure rates and support feedback.
|
||||
- Gradually expand availability once stable.
|
||||
- Rollback path: disable flag for new sessions only; preserve already verified in-flight sessions.
|
||||
|
||||
## Risks and Mitigations
|
||||
|
||||
- Brute-force attempts -> enforce attempt caps, lockouts, and rate limits.
|
||||
- Delivery delays in customer SMS systems -> allow controlled token re-issue.
|
||||
- Support burden from expiry confusion -> clear UX copy and resend guidance.
|
||||
- Concurrency race on consume/attempt updates -> use transactional atomic updates and dedicated tests.
|
||||
- Misconfigured API clients -> explicit permission scopes and structured denial reasons.
|
||||
- Forensic gaps vs privacy over-collection -> reason-coded audits with hashed network metadata only.
|
||||
|
||||
## Open Questions
|
||||
|
||||
- None for v1 scope.
|
||||
- v1.1 exploration candidate: customer-controlled signer-triggered callback/reissue flow with abuse protections.
|
||||
+13
-8
@@ -13,6 +13,7 @@ import { match, P } from 'ts-pattern';
|
||||
|
||||
import { DocumentSigningAuth2FA } from './document-signing-auth-2fa';
|
||||
import { DocumentSigningAuthAccount } from './document-signing-auth-account';
|
||||
import { DocumentSigningAuthExternal2FA } from './document-signing-auth-external-2fa';
|
||||
import { DocumentSigningAuthPasskey } from './document-signing-auth-passkey';
|
||||
import { DocumentSigningAuthPassword } from './document-signing-auth-password';
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
@@ -58,15 +59,8 @@ export const DocumentSigningAuthDialog = ({
|
||||
return;
|
||||
}
|
||||
|
||||
// Reset selected auth type when dialog closes
|
||||
if (!value) {
|
||||
setSelectedAuthType(() => {
|
||||
if (validAuthTypes.length === 1) {
|
||||
return validAuthTypes[0];
|
||||
}
|
||||
|
||||
return null;
|
||||
});
|
||||
setSelectedAuthType(validAuthTypes.length === 1 ? validAuthTypes[0] : null);
|
||||
}
|
||||
|
||||
onOpenChange(value);
|
||||
@@ -123,6 +117,7 @@ export const DocumentSigningAuthDialog = ({
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => <Trans>Passkey</Trans>)
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>2FA</Trans>)
|
||||
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => <Trans>Verification code</Trans>)
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
@@ -132,6 +127,9 @@ export const DocumentSigningAuthDialog = ({
|
||||
.with(DocumentAuth.ACCOUNT, () => <Trans>Sign in to your account</Trans>)
|
||||
.with(DocumentAuth.PASSKEY, () => <Trans>Use your passkey for authentication</Trans>)
|
||||
.with(DocumentAuth.TWO_FACTOR_AUTH, () => <Trans>Enter your 2FA code</Trans>)
|
||||
.with(DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH, () => (
|
||||
<Trans>Enter the verification code provided to you</Trans>
|
||||
))
|
||||
.with(DocumentAuth.PASSWORD, () => <Trans>Enter your password</Trans>)
|
||||
.exhaustive()}
|
||||
</div>
|
||||
@@ -169,6 +167,13 @@ export const DocumentSigningAuthDialog = ({
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH }, () => (
|
||||
<DocumentSigningAuthExternal2FA
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
onReauthFormSubmit={onReauthFormSubmit}
|
||||
/>
|
||||
))
|
||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||
.exhaustive()}
|
||||
</DialogContent>
|
||||
|
||||
+223
@@ -0,0 +1,223 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { Trans } from '@lingui/react/macro';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { SIGNING_2FA_VERIFY_REASON_CODES } from '@documenso/lib/constants/document-auth';
|
||||
import { AppError } from '@documenso/lib/errors/app-error';
|
||||
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||
import { trpc } from '@documenso/trpc/react';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert';
|
||||
import { Button } from '@documenso/ui/primitives/button';
|
||||
import { DialogFooter } from '@documenso/ui/primitives/dialog';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@documenso/ui/primitives/form/form';
|
||||
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
|
||||
|
||||
import { useRequiredDocumentSigningAuthContext } from './document-signing-auth-provider';
|
||||
|
||||
export type DocumentSigningAuthExternal2FAProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (value: boolean) => void;
|
||||
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||
};
|
||||
|
||||
const ZExternal2FAFormSchema = z.object({
|
||||
code: z
|
||||
.string()
|
||||
.length(6, { message: 'Code must be exactly 6 digits' })
|
||||
.regex(/^\d{6}$/, { message: 'Code must contain only digits' }),
|
||||
});
|
||||
|
||||
type TExternal2FAFormSchema = z.infer<typeof ZExternal2FAFormSchema>;
|
||||
|
||||
export const DocumentSigningAuthExternal2FA = ({
|
||||
onReauthFormSubmit,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: DocumentSigningAuthExternal2FAProps) => {
|
||||
const { recipient, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||
useRequiredDocumentSigningAuthContext();
|
||||
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
|
||||
const statusQuery = trpc.envelope.signing2fa.getStatus.useQuery(
|
||||
{ token: recipient.token },
|
||||
{ enabled: open },
|
||||
);
|
||||
|
||||
const verifyMutation = trpc.envelope.signing2fa.verify.useMutation();
|
||||
|
||||
const form = useForm<TExternal2FAFormSchema>({
|
||||
resolver: zodResolver(ZExternal2FAFormSchema),
|
||||
defaultValues: {
|
||||
code: '',
|
||||
},
|
||||
});
|
||||
|
||||
const onFormSubmit = async ({ code }: TExternal2FAFormSchema) => {
|
||||
try {
|
||||
setIsCurrentlyAuthenticating(true);
|
||||
setFormError(null);
|
||||
|
||||
await verifyMutation.mutateAsync({
|
||||
token: recipient.token,
|
||||
code,
|
||||
});
|
||||
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
});
|
||||
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
const error = AppError.parseError(err);
|
||||
|
||||
if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_ATTEMPT_LIMIT_REACHED) {
|
||||
setFormError('Too many failed attempts. Please request a new code.');
|
||||
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_TOKEN_EXPIRED) {
|
||||
setFormError('The code has expired. Please request a new code.');
|
||||
} else if (error.message === SIGNING_2FA_VERIFY_REASON_CODES.TWO_FA_NOT_ISSUED) {
|
||||
setFormError('No code has been issued yet. Please contact the document sender.');
|
||||
} else {
|
||||
setFormError('Invalid code. Please try again.');
|
||||
}
|
||||
|
||||
await statusQuery.refetch();
|
||||
form.reset({ code: '' });
|
||||
} finally {
|
||||
setIsCurrentlyAuthenticating(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
form.reset({ code: '' });
|
||||
setFormError(null);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
const attemptsRemaining = statusQuery.data?.attemptsRemaining ?? null;
|
||||
const hasActiveToken = statusQuery.data?.hasActiveToken ?? false;
|
||||
const hasValidProof = statusQuery.data?.hasValidProof ?? false;
|
||||
|
||||
if (hasValidProof) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert>
|
||||
<AlertDescription>
|
||||
<Trans>Your identity has already been verified. You can proceed to sign.</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
await onReauthFormSubmit({
|
||||
type: DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
});
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
<Trans>Continue</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasActiveToken && !statusQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Alert variant="warning">
|
||||
<AlertTitle>
|
||||
<Trans>Verification code required</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>
|
||||
<Trans>
|
||||
A verification code is required to sign this document. Please contact the document
|
||||
sender to request your code.
|
||||
</Trans>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Close</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Trans>Enter the 6-digit verification code that was provided to you.</Trans>
|
||||
</p>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="code"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel required>
|
||||
<Trans>Verification code</Trans>
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
|
||||
{Array(6)
|
||||
.fill(null)
|
||||
.map((_, i) => (
|
||||
<PinInputGroup key={i}>
|
||||
<PinInputSlot index={i} />
|
||||
</PinInputGroup>
|
||||
))}
|
||||
</PinInput>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{attemptsRemaining !== null && attemptsRemaining > 0 && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<Trans>{attemptsRemaining} attempts remaining</Trans>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{formError && (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>
|
||||
<Trans>Verification failed</Trans>
|
||||
</AlertTitle>
|
||||
<AlertDescription>{formError}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||
<Trans>Cancel</Trans>
|
||||
</Button>
|
||||
|
||||
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||
<Trans>Verify</Trans>
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
</fieldset>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
+7
-7
@@ -61,13 +61,13 @@ export const useRequiredDocumentSigningAuthContext = () => {
|
||||
return context;
|
||||
};
|
||||
|
||||
export interface DocumentSigningAuthProviderProps {
|
||||
export type DocumentSigningAuthProviderProps = {
|
||||
documentAuthOptions: Envelope['authOptions'];
|
||||
recipient: SigningAuthRecipient;
|
||||
isDirectTemplate?: boolean;
|
||||
user?: SessionUser | null;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
};
|
||||
|
||||
export const DocumentSigningAuthProvider = ({
|
||||
documentAuthOptions: initialDocumentAuthOptions,
|
||||
@@ -169,12 +169,12 @@ export const DocumentSigningAuthProvider = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [passkeyData.passkeys]);
|
||||
|
||||
// Assume that a user must be logged in for any auth requirements.
|
||||
const authMethodsRequiringLogin = derivedRecipientActionAuth?.filter(
|
||||
(method) => method !== DocumentAuth.EXPLICIT_NONE && method !== DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
);
|
||||
|
||||
const isAuthRedirectRequired = Boolean(
|
||||
derivedRecipientActionAuth &&
|
||||
derivedRecipientActionAuth.length > 0 &&
|
||||
!derivedRecipientActionAuth.includes(DocumentAuth.EXPLICIT_NONE) &&
|
||||
user?.email !== recipient.email,
|
||||
authMethodsRequiringLogin && authMethodsRequiringLogin.length > 0 && user?.email !== recipient.email,
|
||||
);
|
||||
|
||||
const refetchPasskeys = async () => {
|
||||
|
||||
@@ -106,8 +106,12 @@ export const DocumentSigningAutoSign = ({ recipient, fields }: DocumentSigningAu
|
||||
}))
|
||||
.with(undefined, () => undefined)
|
||||
.with(
|
||||
P.union(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD),
|
||||
// This is a bit dirty, but the sentinel value used here is incredibly short-lived.
|
||||
P.union(
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
),
|
||||
() => 'NOT_SUPPORTED' as const,
|
||||
)
|
||||
.exhaustive();
|
||||
|
||||
@@ -150,6 +150,7 @@ export default function SigningCertificate({ loaderData }: Route.ComponentProps)
|
||||
let authLevel = match(actionAuthMethod)
|
||||
.with('ACCOUNT', () => _(msg`Account Re-Authentication`))
|
||||
.with('TWO_FACTOR_AUTH', () => _(msg`Two-Factor Re-Authentication`))
|
||||
.with('EXTERNAL_TWO_FACTOR_AUTH', () => _(msg`External Two-Factor Re-Authentication`))
|
||||
.with('PASSWORD', () => _(msg`Password Re-Authentication`))
|
||||
.with('PASSKEY', () => _(msg`Passkey Re-Authentication`))
|
||||
.with('EXPLICIT_NONE', () => _(msg`Email`))
|
||||
|
||||
@@ -26,8 +26,21 @@ 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)`,
|
||||
},
|
||||
} satisfies Record<TDocumentAuth, DocumentAuthTypeData>;
|
||||
|
||||
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;
|
||||
|
||||
@@ -115,11 +115,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, {
|
||||
|
||||
@@ -31,6 +31,7 @@ type IsRecipientAuthorizedOptions = {
|
||||
* using the user ID.
|
||||
*/
|
||||
authOptions?: TDocumentAuthMethods;
|
||||
recipientToken?: string;
|
||||
};
|
||||
|
||||
const getUserByEmail = async (email: string) => {
|
||||
@@ -56,6 +57,7 @@ export const isRecipientAuthorized = async ({
|
||||
recipient,
|
||||
userId,
|
||||
authOptions,
|
||||
recipientToken,
|
||||
}: IsRecipientAuthorizedOptions): Promise<boolean> => {
|
||||
const { derivedRecipientAccessAuth, derivedRecipientActionAuth } = extractDocumentAuthMethods({
|
||||
documentAuth: documentAuthOptions,
|
||||
@@ -163,6 +165,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({
|
||||
|
||||
@@ -101,6 +101,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,245 @@
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { SIGNING_2FA_VERIFY_REASON_CODES } from '../../constants/document-auth';
|
||||
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';
|
||||
|
||||
export { SIGNING_2FA_VERIFY_REASON_CODES };
|
||||
|
||||
const PROOF_TTL_MINUTES = 10;
|
||||
|
||||
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,
|
||||
});
|
||||
};
|
||||
@@ -54,6 +54,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([
|
||||
@@ -710,6 +718,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(),
|
||||
}),
|
||||
});
|
||||
|
||||
/**
|
||||
* Event: Recipient's signing window expired.
|
||||
*/
|
||||
@@ -769,6 +830,12 @@ export const ZDocumentAuditLogSchema = ZDocumentAuditLogBaseSchema.and(
|
||||
ZDocumentAuditLogEventRecipientAddedSchema,
|
||||
ZDocumentAuditLogEventRecipientUpdatedSchema,
|
||||
ZDocumentAuditLogEventRecipientRemovedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenIssuedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenIssueDeniedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenVerifySucceededSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenVerifyFailedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenConsumedSchema,
|
||||
ZDocumentAuditLogEventExternal2FATokenRevokedSchema,
|
||||
ZDocumentAuditLogEventRecipientExpiredSchema,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -5,7 +5,14 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
|
||||
/**
|
||||
* All the available types of document authentication options for both access and action.
|
||||
*/
|
||||
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'TWO_FACTOR_AUTH', 'PASSWORD', 'EXPLICIT_NONE']);
|
||||
export const ZDocumentAuthTypesSchema = z.enum([
|
||||
'ACCOUNT',
|
||||
'PASSKEY',
|
||||
'TWO_FACTOR_AUTH',
|
||||
'EXTERNAL_TWO_FACTOR_AUTH',
|
||||
'PASSWORD',
|
||||
'EXPLICIT_NONE',
|
||||
]);
|
||||
|
||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||
|
||||
@@ -34,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.
|
||||
*/
|
||||
@@ -42,6 +53,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
]);
|
||||
|
||||
@@ -67,10 +79,17 @@ export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
]);
|
||||
export const ZDocumentActionAuthTypesSchema = z
|
||||
.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, DocumentAuth.PASSWORD])
|
||||
.enum([
|
||||
DocumentAuth.ACCOUNT,
|
||||
DocumentAuth.PASSKEY,
|
||||
DocumentAuth.TWO_FACTOR_AUTH,
|
||||
DocumentAuth.EXTERNAL_TWO_FACTOR_AUTH,
|
||||
DocumentAuth.PASSWORD,
|
||||
])
|
||||
.describe(
|
||||
'The type of authentication required for the recipient to sign the document. This field is restricted to Enterprise plan users only.',
|
||||
);
|
||||
@@ -97,6 +116,7 @@ export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
||||
ZDocumentAuthAccountSchema,
|
||||
ZDocumentAuthPasskeySchema,
|
||||
ZDocumentAuth2FASchema,
|
||||
ZDocumentAuthExternal2FASchema,
|
||||
ZDocumentAuthPasswordSchema,
|
||||
ZDocumentAuthExplicitNoneSchema,
|
||||
]);
|
||||
@@ -105,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,
|
||||
])
|
||||
|
||||
@@ -34,6 +34,8 @@ export const ZClaimFlagsSchema = z.object({
|
||||
|
||||
allowLegacyEnvelopes: z.boolean().optional(),
|
||||
|
||||
externalSigning2fa: z.boolean().optional(),
|
||||
|
||||
signingReminders: z.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -102,6 +104,11 @@ export const SUBSCRIPTION_CLAIM_FEATURE_FLAGS: Record<
|
||||
key: 'allowLegacyEnvelopes',
|
||||
label: 'Allow Legacy Envelopes',
|
||||
},
|
||||
externalSigning2fa: {
|
||||
key: 'externalSigning2fa',
|
||||
label: 'External signing 2FA',
|
||||
isEnterprise: true,
|
||||
},
|
||||
signingReminders: {
|
||||
key: 'signingReminders',
|
||||
label: 'Signing reminders',
|
||||
|
||||
@@ -597,6 +597,48 @@ export const formatDocumentAuditLogAction = (i18n: I18n, auditLog: TDocumentAudi
|
||||
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;
|
||||
@@ -437,6 +437,9 @@ model Envelope {
|
||||
|
||||
envelopeAttachments EnvelopeAttachment[]
|
||||
|
||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
||||
|
||||
@@index([type])
|
||||
@@index([status])
|
||||
@@index([userId])
|
||||
@@ -604,6 +607,9 @@ model Recipient {
|
||||
fields Field[]
|
||||
signatures Signature[]
|
||||
|
||||
signingTwoFactorTokens SigningTwoFactorToken[]
|
||||
signingSessionTwoFactorProofs SigningSessionTwoFactorProof[]
|
||||
|
||||
@@index([token])
|
||||
@@index([email])
|
||||
@@index([envelopeId])
|
||||
@@ -1115,6 +1121,58 @@ model Counter {
|
||||
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])
|
||||
}
|
||||
|
||||
model RateLimit {
|
||||
key String
|
||||
action String
|
||||
|
||||
@@ -34,6 +34,9 @@ import { saveAsTemplateRoute } from './save-as-template';
|
||||
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';
|
||||
@@ -97,5 +100,10 @@ export const envelopeRouter = router({
|
||||
saveAsTemplate: saveAsTemplateRoute,
|
||||
distribute: distributeEnvelopeRoute,
|
||||
redistribute: redistributeEnvelopeRoute,
|
||||
signing2fa: {
|
||||
issue: issueSigningTwoFactorTokenRoute,
|
||||
verify: verifySigningTwoFactorTokenRoute,
|
||||
getStatus: getSigningTwoFactorStatusRoute,
|
||||
},
|
||||
signingStatus: signingStatusEnvelopeRoute,
|
||||
});
|
||||
|
||||
@@ -176,6 +176,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