mirror of
https://github.com/documenso/documenso.git
synced 2026-07-24 17:04:12 +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:
@@ -30,6 +30,9 @@ import { redistributeEnvelopeRoute } from './redistribute-envelope';
|
||||
import { setEnvelopeFieldsRoute } from './set-envelope-fields';
|
||||
import { setEnvelopeRecipientsRoute } from './set-envelope-recipients';
|
||||
import { signEnvelopeFieldRoute } from './sign-envelope-field';
|
||||
import { getSigningTwoFactorStatusRoute } from './signing-2fa/get-signing-two-factor-status';
|
||||
import { issueSigningTwoFactorTokenRoute } from './signing-2fa/issue-signing-two-factor-token';
|
||||
import { verifySigningTwoFactorTokenRoute } from './signing-2fa/verify-signing-two-factor-token';
|
||||
import { signingStatusEnvelopeRoute } from './signing-status-envelope';
|
||||
import { updateEnvelopeRoute } from './update-envelope';
|
||||
import { updateEnvelopeItemsRoute } from './update-envelope-items';
|
||||
@@ -87,5 +90,10 @@ export const envelopeRouter = router({
|
||||
duplicate: duplicateEnvelopeRoute,
|
||||
distribute: distributeEnvelopeRoute,
|
||||
redistribute: redistributeEnvelopeRoute,
|
||||
signing2fa: {
|
||||
issue: issueSigningTwoFactorTokenRoute,
|
||||
verify: verifySigningTwoFactorTokenRoute,
|
||||
getStatus: getSigningTwoFactorStatusRoute,
|
||||
},
|
||||
signingStatus: signingStatusEnvelopeRoute,
|
||||
});
|
||||
|
||||
@@ -182,6 +182,7 @@ export const signEnvelopeFieldRoute = procedure
|
||||
field,
|
||||
userId: user?.id,
|
||||
authOptions,
|
||||
recipientToken: token,
|
||||
});
|
||||
|
||||
const assistant = recipient.role === RecipientRole.ASSISTANT ? recipient : undefined;
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getSigningTwoFactorStatus } from '@documenso/lib/server-only/signing-2fa/get-signing-two-factor-status';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZGetSigningTwoFactorStatusRequestSchema,
|
||||
ZGetSigningTwoFactorStatusResponseSchema,
|
||||
} from './get-signing-two-factor-status.types';
|
||||
|
||||
export const getSigningTwoFactorStatusRoute = procedure
|
||||
.input(ZGetSigningTwoFactorStatusRequestSchema)
|
||||
.output(ZGetSigningTwoFactorStatusResponseSchema)
|
||||
.query(async ({ input, ctx }) => {
|
||||
const { token } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
token: '***',
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
return await getSigningTwoFactorStatus({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
sessionId: token,
|
||||
});
|
||||
});
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZGetSigningTwoFactorStatusRequestSchema = z.object({
|
||||
token: z.string().describe('The recipient signing token from the signing URL.'),
|
||||
});
|
||||
|
||||
export const ZGetSigningTwoFactorStatusResponseSchema = z.object({
|
||||
required: z.boolean().describe('Whether external 2FA is required for this recipient.'),
|
||||
hasActiveToken: z.boolean().describe('Whether an active (unexpired) token exists.'),
|
||||
hasValidProof: z.boolean().describe('Whether a valid session proof exists.'),
|
||||
tokenExpiresAt: z.date().nullable().describe('When the active token expires, if any.'),
|
||||
proofExpiresAt: z.date().nullable().describe('When the session proof expires, if any.'),
|
||||
attemptsRemaining: z
|
||||
.number()
|
||||
.nullable()
|
||||
.describe('Remaining verification attempts for the active token.'),
|
||||
});
|
||||
|
||||
export type TGetSigningTwoFactorStatusRequest = z.infer<
|
||||
typeof ZGetSigningTwoFactorStatusRequestSchema
|
||||
>;
|
||||
export type TGetSigningTwoFactorStatusResponse = z.infer<
|
||||
typeof ZGetSigningTwoFactorStatusResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,53 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { getApiTokenByToken } from '@documenso/lib/server-only/public-api/get-api-token-by-token';
|
||||
import { issueSigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/issue-signing-two-factor-token';
|
||||
|
||||
import { authenticatedProcedure } from '../../trpc';
|
||||
import {
|
||||
ZIssueSigningTwoFactorTokenRequestSchema,
|
||||
ZIssueSigningTwoFactorTokenResponseSchema,
|
||||
issueSigningTwoFactorTokenMeta,
|
||||
} from './issue-signing-two-factor-token.types';
|
||||
|
||||
export const issueSigningTwoFactorTokenRoute = authenticatedProcedure
|
||||
.meta(issueSigningTwoFactorTokenMeta)
|
||||
.input(ZIssueSigningTwoFactorTokenRequestSchema)
|
||||
.output(ZIssueSigningTwoFactorTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { envelopeId, recipientId } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
envelopeId,
|
||||
recipientId,
|
||||
},
|
||||
});
|
||||
|
||||
const authorizationHeader = ctx.req.headers.get('authorization');
|
||||
|
||||
if (!authorizationHeader) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token required to issue signing 2FA tokens',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const [token] = (authorizationHeader || '').split('Bearer ').filter((s) => s.length > 0);
|
||||
|
||||
if (!token) {
|
||||
throw new AppError(AppErrorCode.UNAUTHORIZED, {
|
||||
message: 'API token required to issue signing 2FA tokens',
|
||||
statusCode: 401,
|
||||
});
|
||||
}
|
||||
|
||||
const apiToken = await getApiTokenByToken({ token });
|
||||
|
||||
const result = await issueSigningTwoFactorToken({
|
||||
recipientId,
|
||||
envelopeId,
|
||||
apiTokenId: apiToken.id,
|
||||
});
|
||||
|
||||
return result;
|
||||
});
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { TrpcRouteMeta } from '../../trpc';
|
||||
|
||||
export const issueSigningTwoFactorTokenMeta: TrpcRouteMeta = {
|
||||
openapi: {
|
||||
method: 'POST',
|
||||
path: '/envelope/signing-2fa/issue',
|
||||
summary: 'Issue a signing 2FA token',
|
||||
description:
|
||||
'Issue a one-time signing two-factor authentication token for a recipient. The caller is responsible for delivering the token to the signer through their own channel (e.g., SMS).',
|
||||
tags: ['Envelope'],
|
||||
},
|
||||
};
|
||||
|
||||
export const ZIssueSigningTwoFactorTokenRequestSchema = z.object({
|
||||
envelopeId: z.string().describe('The ID of the envelope.'),
|
||||
recipientId: z.number().describe('The ID of the recipient to issue the token for.'),
|
||||
});
|
||||
|
||||
export const ZIssueSigningTwoFactorTokenResponseSchema = z.object({
|
||||
token: z.string().describe('The plaintext one-time token. Visible exactly once.'),
|
||||
tokenId: z.string().describe('The ID of the created token record.'),
|
||||
expiresAt: z.date().describe('When the token expires.'),
|
||||
ttlSeconds: z.number().describe('Token time-to-live in seconds.'),
|
||||
attemptLimit: z.number().describe('Maximum verification attempts allowed.'),
|
||||
issuedAt: z.date().describe('When the token was issued.'),
|
||||
});
|
||||
|
||||
export type TIssueSigningTwoFactorTokenRequest = z.infer<
|
||||
typeof ZIssueSigningTwoFactorTokenRequestSchema
|
||||
>;
|
||||
export type TIssueSigningTwoFactorTokenResponse = z.infer<
|
||||
typeof ZIssueSigningTwoFactorTokenResponseSchema
|
||||
>;
|
||||
@@ -0,0 +1,51 @@
|
||||
import { AppError, AppErrorCode } from '@documenso/lib/errors/app-error';
|
||||
import { verifySigningTwoFactorToken } from '@documenso/lib/server-only/signing-2fa/verify-signing-two-factor-token';
|
||||
import { prisma } from '@documenso/prisma';
|
||||
|
||||
import { procedure } from '../../trpc';
|
||||
import {
|
||||
ZVerifySigningTwoFactorTokenRequestSchema,
|
||||
ZVerifySigningTwoFactorTokenResponseSchema,
|
||||
} from './verify-signing-two-factor-token.types';
|
||||
|
||||
export const verifySigningTwoFactorTokenRoute = procedure
|
||||
.input(ZVerifySigningTwoFactorTokenRequestSchema)
|
||||
.output(ZVerifySigningTwoFactorTokenResponseSchema)
|
||||
.mutation(async ({ input, ctx }) => {
|
||||
const { token, code } = input;
|
||||
|
||||
ctx.logger.info({
|
||||
input: {
|
||||
token: '***',
|
||||
},
|
||||
});
|
||||
|
||||
const recipient = await prisma.recipient.findFirst({
|
||||
where: {
|
||||
token,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
envelopeId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!recipient) {
|
||||
throw new AppError(AppErrorCode.NOT_FOUND, {
|
||||
message: 'Recipient not found',
|
||||
statusCode: 404,
|
||||
});
|
||||
}
|
||||
|
||||
const result = await verifySigningTwoFactorToken({
|
||||
recipientId: recipient.id,
|
||||
envelopeId: recipient.envelopeId,
|
||||
token: code,
|
||||
sessionId: token,
|
||||
});
|
||||
|
||||
return {
|
||||
verified: result!.verified,
|
||||
expiresAt: result!.expiresAt,
|
||||
};
|
||||
});
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ZVerifySigningTwoFactorTokenRequestSchema = z.object({
|
||||
token: z.string().describe('The recipient signing token from the signing URL.'),
|
||||
code: z
|
||||
.string()
|
||||
.min(6)
|
||||
.max(6)
|
||||
.regex(/^\d{6}$/)
|
||||
.describe('The 6-digit one-time code to verify.'),
|
||||
});
|
||||
|
||||
export const ZVerifySigningTwoFactorTokenResponseSchema = z.object({
|
||||
verified: z.boolean().describe('Whether the code was successfully verified.'),
|
||||
expiresAt: z.date().describe('When the session proof expires.'),
|
||||
});
|
||||
|
||||
export type TVerifySigningTwoFactorTokenRequest = z.infer<
|
||||
typeof ZVerifySigningTwoFactorTokenRequestSchema
|
||||
>;
|
||||
export type TVerifySigningTwoFactorTokenResponse = z.infer<
|
||||
typeof ZVerifySigningTwoFactorTokenResponseSchema
|
||||
>;
|
||||
Reference in New Issue
Block a user