mirror of
https://github.com/documenso/documenso.git
synced 2025-11-15 01:01:49 +10:00
feat: add document auth 2FA
This commit is contained in:
@ -0,0 +1,161 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { AppError } from '@documenso/lib/errors/app-error';
|
||||||
|
import { DocumentAuth, type TRecipientActionAuth } from '@documenso/lib/types/document-auth';
|
||||||
|
import { RecipientRole } from '@documenso/prisma/client';
|
||||||
|
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 { Input } from '@documenso/ui/primitives/input';
|
||||||
|
|
||||||
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
|
|
||||||
|
export type DocumentActionAuth2FAProps = {
|
||||||
|
actionTarget?: 'FIELD' | 'DOCUMENT';
|
||||||
|
actionVerb?: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (value: boolean) => void;
|
||||||
|
onReauthFormSubmit: (values?: TRecipientActionAuth) => Promise<void> | void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const Z2FAAuthFormSchema = z.object({
|
||||||
|
token: z
|
||||||
|
.string()
|
||||||
|
.min(4, { message: 'Token must at least 4 characters long' })
|
||||||
|
.max(10, { message: 'Token must be at most 10 characters long' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
type T2FAAuthFormSchema = z.infer<typeof Z2FAAuthFormSchema>;
|
||||||
|
|
||||||
|
export const DocumentActionAuth2FA = ({
|
||||||
|
actionTarget = 'FIELD',
|
||||||
|
actionVerb = 'sign',
|
||||||
|
onReauthFormSubmit,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
}: DocumentActionAuth2FAProps) => {
|
||||||
|
const { recipient, user, isCurrentlyAuthenticating, setIsCurrentlyAuthenticating } =
|
||||||
|
useRequiredDocumentAuthContext();
|
||||||
|
|
||||||
|
const form = useForm<T2FAAuthFormSchema>({
|
||||||
|
resolver: zodResolver(Z2FAAuthFormSchema),
|
||||||
|
defaultValues: {
|
||||||
|
token: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [formErrorCode, setFormErrorCode] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const onFormSubmit = async ({ token }: T2FAAuthFormSchema) => {
|
||||||
|
try {
|
||||||
|
setIsCurrentlyAuthenticating(true);
|
||||||
|
|
||||||
|
await onReauthFormSubmit({
|
||||||
|
type: DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
|
token,
|
||||||
|
});
|
||||||
|
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (err) {
|
||||||
|
setIsCurrentlyAuthenticating(false);
|
||||||
|
|
||||||
|
const error = AppError.parseError(err);
|
||||||
|
setFormErrorCode(error.code);
|
||||||
|
|
||||||
|
// Todo: Alert.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
form.reset({
|
||||||
|
token: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
setFormErrorCode(null);
|
||||||
|
}, [open, form]);
|
||||||
|
|
||||||
|
if (!user?.twoFactorEnabled) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Alert variant="warning">
|
||||||
|
<AlertDescription>
|
||||||
|
{recipient.role === RecipientRole.VIEWER && actionTarget === 'DOCUMENT'
|
||||||
|
? 'You need to setup 2FA to mark this document as viewed.'
|
||||||
|
: `You need to setup 2FA to ${actionVerb.toLowerCase()} this ${actionTarget.toLowerCase()}.`}
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Close
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="button" asChild>
|
||||||
|
<Link href="/settings/security">Setup 2FA</Link>
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Form {...form}>
|
||||||
|
<form onSubmit={form.handleSubmit(onFormSubmit)}>
|
||||||
|
<fieldset disabled={isCurrentlyAuthenticating}>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="token"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel required>2FA token</FormLabel>
|
||||||
|
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} placeholder="Token" />
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{formErrorCode && (
|
||||||
|
<Alert variant="destructive">
|
||||||
|
<AlertTitle>Unauthorized</AlertTitle>
|
||||||
|
<AlertDescription>
|
||||||
|
We were unable to verify your details. Please try again or contact support
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button type="button" variant="secondary" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isCurrentlyAuthenticating}>
|
||||||
|
Sign
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -14,6 +14,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@documenso/ui/primitives/dialog';
|
} from '@documenso/ui/primitives/dialog';
|
||||||
|
|
||||||
|
import { DocumentActionAuth2FA } from './document-action-auth-2fa';
|
||||||
import { DocumentActionAuthAccount } from './document-action-auth-account';
|
import { DocumentActionAuthAccount } from './document-action-auth-account';
|
||||||
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
|
import { DocumentActionAuthPasskey } from './document-action-auth-passkey';
|
||||||
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
import { useRequiredDocumentAuthContext } from './document-auth-provider';
|
||||||
@ -74,6 +75,13 @@ export const DocumentActionAuthDialog = ({
|
|||||||
onReauthFormSubmit={onReauthFormSubmit}
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
|
.with({ documentAuthType: DocumentAuth.TWO_FACTOR_AUTH }, () => (
|
||||||
|
<DocumentActionAuth2FA
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
onReauthFormSubmit={onReauthFormSubmit}
|
||||||
|
/>
|
||||||
|
))
|
||||||
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
.with({ documentAuthType: DocumentAuth.EXPLICIT_NONE }, () => null)
|
||||||
.exhaustive()}
|
.exhaustive()}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@ -142,7 +142,7 @@ export const DocumentAuthProvider = ({
|
|||||||
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
.with(DocumentAuth.EXPLICIT_NONE, () => ({
|
||||||
type: DocumentAuth.EXPLICIT_NONE,
|
type: DocumentAuth.EXPLICIT_NONE,
|
||||||
}))
|
}))
|
||||||
.with(DocumentAuth.PASSKEY, null, () => null)
|
.with(DocumentAuth.PASSKEY, DocumentAuth.TWO_FACTOR_AUTH, null, () => null)
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
|
|
||||||
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
const executeActionAuthProcedure = async (options: ExecuteActionAuthProcedureOptions) => {
|
||||||
|
|||||||
@ -24,6 +24,10 @@ export const DOCUMENT_AUTH_TYPES: Record<string, DocumentAuthTypeData> = {
|
|||||||
key: DocumentAuth.PASSKEY,
|
key: DocumentAuth.PASSKEY,
|
||||||
value: 'Require passkey',
|
value: 'Require passkey',
|
||||||
},
|
},
|
||||||
|
[DocumentAuth.TWO_FACTOR_AUTH]: {
|
||||||
|
key: DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
|
value: 'Require 2FA',
|
||||||
|
},
|
||||||
[DocumentAuth.EXPLICIT_NONE]: {
|
[DocumentAuth.EXPLICIT_NONE]: {
|
||||||
key: DocumentAuth.EXPLICIT_NONE,
|
key: DocumentAuth.EXPLICIT_NONE,
|
||||||
value: 'None (Overrides global settings)',
|
value: 'None (Overrides global settings)',
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { match } from 'ts-pattern';
|
|||||||
import { prisma } from '@documenso/prisma';
|
import { prisma } from '@documenso/prisma';
|
||||||
import type { Document, Recipient } from '@documenso/prisma/client';
|
import type { Document, Recipient } from '@documenso/prisma/client';
|
||||||
|
|
||||||
|
import { verifyTwoFactorAuthenticationToken } from '../2fa/verify-2fa-token';
|
||||||
import { AppError, AppErrorCode } from '../../errors/app-error';
|
import { AppError, AppErrorCode } from '../../errors/app-error';
|
||||||
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
|
import type { TDocumentAuth, TDocumentAuthMethods } from '../../types/document-auth';
|
||||||
import { DocumentAuth } from '../../types/document-auth';
|
import { DocumentAuth } from '../../types/document-auth';
|
||||||
@ -96,6 +97,23 @@ export const isRecipientAuthorized = async ({
|
|||||||
tokenReference,
|
tokenReference,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
.with({ type: DocumentAuth.TWO_FACTOR_AUTH }, async ({ token }) => {
|
||||||
|
const user = await prisma.user.findFirst({
|
||||||
|
where: {
|
||||||
|
id: userId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Should not be possible.
|
||||||
|
if (!user) {
|
||||||
|
throw new AppError(AppErrorCode.NOT_FOUND, 'User not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
return await verifyTwoFactorAuthenticationToken({
|
||||||
|
user,
|
||||||
|
totpCode: token,
|
||||||
|
});
|
||||||
|
})
|
||||||
.exhaustive();
|
.exhaustive();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,12 @@ import { ZAuthenticationResponseJSONSchema } from './webauthn';
|
|||||||
/**
|
/**
|
||||||
* All the available types of document authentication options for both access and action.
|
* All the available types of document authentication options for both access and action.
|
||||||
*/
|
*/
|
||||||
export const ZDocumentAuthTypesSchema = z.enum(['ACCOUNT', 'PASSKEY', 'EXPLICIT_NONE']);
|
export const ZDocumentAuthTypesSchema = z.enum([
|
||||||
|
'ACCOUNT',
|
||||||
|
'PASSKEY',
|
||||||
|
'TWO_FACTOR_AUTH',
|
||||||
|
'EXPLICIT_NONE',
|
||||||
|
]);
|
||||||
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
export const DocumentAuth = ZDocumentAuthTypesSchema.Enum;
|
||||||
|
|
||||||
const ZDocumentAuthAccountSchema = z.object({
|
const ZDocumentAuthAccountSchema = z.object({
|
||||||
@ -22,6 +27,11 @@ const ZDocumentAuthPasskeySchema = z.object({
|
|||||||
tokenReference: z.string().min(1),
|
tokenReference: z.string().min(1),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const ZDocumentAuth2FASchema = z.object({
|
||||||
|
type: z.literal(DocumentAuth.TWO_FACTOR_AUTH),
|
||||||
|
token: z.string().min(4).max(10),
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* All the document auth methods for both accessing and actioning.
|
* All the document auth methods for both accessing and actioning.
|
||||||
*/
|
*/
|
||||||
@ -29,6 +39,7 @@ export const ZDocumentAuthMethodsSchema = z.discriminatedUnion('type', [
|
|||||||
ZDocumentAuthAccountSchema,
|
ZDocumentAuthAccountSchema,
|
||||||
ZDocumentAuthExplicitNoneSchema,
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
ZDocumentAuthPasskeySchema,
|
ZDocumentAuthPasskeySchema,
|
||||||
|
ZDocumentAuth2FASchema,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,8 +58,13 @@ export const ZDocumentAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
|||||||
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
export const ZDocumentActionAuthSchema = z.discriminatedUnion('type', [
|
||||||
ZDocumentAuthAccountSchema,
|
ZDocumentAuthAccountSchema,
|
||||||
ZDocumentAuthPasskeySchema,
|
ZDocumentAuthPasskeySchema,
|
||||||
|
ZDocumentAuth2FASchema,
|
||||||
|
]);
|
||||||
|
export const ZDocumentActionAuthTypesSchema = z.enum([
|
||||||
|
DocumentAuth.ACCOUNT,
|
||||||
|
DocumentAuth.PASSKEY,
|
||||||
|
DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
]);
|
]);
|
||||||
export const ZDocumentActionAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT, DocumentAuth.PASSKEY]);
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The recipient access auth methods.
|
* The recipient access auth methods.
|
||||||
@ -68,11 +84,13 @@ export const ZRecipientAccessAuthTypesSchema = z.enum([DocumentAuth.ACCOUNT]);
|
|||||||
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
export const ZRecipientActionAuthSchema = z.discriminatedUnion('type', [
|
||||||
ZDocumentAuthAccountSchema,
|
ZDocumentAuthAccountSchema,
|
||||||
ZDocumentAuthPasskeySchema,
|
ZDocumentAuthPasskeySchema,
|
||||||
|
ZDocumentAuth2FASchema,
|
||||||
ZDocumentAuthExplicitNoneSchema,
|
ZDocumentAuthExplicitNoneSchema,
|
||||||
]);
|
]);
|
||||||
export const ZRecipientActionAuthTypesSchema = z.enum([
|
export const ZRecipientActionAuthTypesSchema = z.enum([
|
||||||
DocumentAuth.ACCOUNT,
|
DocumentAuth.ACCOUNT,
|
||||||
DocumentAuth.PASSKEY,
|
DocumentAuth.PASSKEY,
|
||||||
|
DocumentAuth.TWO_FACTOR_AUTH,
|
||||||
DocumentAuth.EXPLICIT_NONE,
|
DocumentAuth.EXPLICIT_NONE,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user