feat: require 2fa code before account is deleted

This commit is contained in:
Ephraim Atta-Duncan
2024-01-21 15:38:32 +00:00
parent 7762b1db65
commit 9e433af112
5 changed files with 98 additions and 35 deletions

View File

@ -7,6 +7,7 @@ import { signOut } from 'next-auth/react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa';
import type { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { TRPCClientError } from '@documenso/trpc/client'; import { TRPCClientError } from '@documenso/trpc/client';
import { trpc } from '@documenso/trpc/react'; import { trpc } from '@documenso/trpc/react';
@ -41,6 +42,11 @@ export const ZProfileFormSchema = z.object({
signature: z.string().min(1, 'Signature Pad cannot be empty'), signature: z.string().min(1, 'Signature Pad cannot be empty'),
}); });
export const ZTwoFactorAuthTokenSchema = z.object({
token: z.string(),
});
export type TTwoFactorAuthTokenSchema = z.infer<typeof ZTwoFactorAuthTokenSchema>;
export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>; export type TProfileFormSchema = z.infer<typeof ZProfileFormSchema>;
export type ProfileFormProps = { export type ProfileFormProps = {
@ -61,7 +67,15 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
resolver: zodResolver(ZProfileFormSchema), resolver: zodResolver(ZProfileFormSchema),
}); });
const deleteAccountTwoFactorTokenForm = useForm<TTwoFactorAuthTokenSchema>({
defaultValues: {
token: '',
},
resolver: zodResolver(ZTwoFactorAuthTokenSchema),
});
const isSubmitting = form.formState.isSubmitting; const isSubmitting = form.formState.isSubmitting;
const hasTwoFactorAuthentication = user.twoFactorEnabled;
const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation();
const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } =
@ -101,9 +115,20 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
const onDeleteAccount = async () => { const onDeleteAccount = async () => {
try { try {
await deleteAccount(); const { token } = deleteAccountTwoFactorTokenForm.getValues();
await signOut({ callbackUrl: '/' }); if (!token) {
throw new Error('Please enter your Two Factor Authentication token.');
}
await validateTwoFactorAuthentication({
totpCode: token,
user,
}).catch(() => {
throw new Error('We were unable to validate your Two Factor Authentication token.');
});
await deleteAccount();
toast({ toast({
title: 'Account deleted', title: 'Account deleted',
@ -111,9 +136,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
duration: 5000, duration: 5000,
}); });
// logout after deleting account await signOut({ callbackUrl: '/' });
router.push('/');
} catch (err) { } catch (err) {
if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') {
toast({ toast({
@ -126,6 +149,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
title: 'An unknown error occurred', title: 'An unknown error occurred',
variant: 'destructive', variant: 'destructive',
description: description:
err.message ??
'We encountered an unknown error while attempting to delete your account. Please try again later.', 'We encountered an unknown error while attempting to delete your account. Please try again later.',
}); });
} }
@ -193,36 +217,73 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => {
irreversible and will cancel your subscription, so proceed with caution. irreversible and will cancel your subscription, so proceed with caution.
</CardContent> </CardContent>
<CardFooter className="justify-end pb-4 pr-4"> <CardFooter className="justify-end pb-4 pr-4">
<Dialog> <Form {...deleteAccountTwoFactorTokenForm}>
<DialogTrigger asChild> <form
<Button variant="destructive">Delete Account</Button> onSubmit={deleteAccountTwoFactorTokenForm.handleSubmit(() => {
</DialogTrigger> console.log('delete account');
<DialogContent> })}
<DialogHeader> >
<DialogTitle>Delete Account</DialogTitle> <Dialog>
<DialogDescription> <DialogTrigger asChild>
Documenso will delete{' '} <Button
<span className="font-semibold">all of your documents</span>, along with all of onClick={() => {
your completed documents, signatures, and all other resources belonging to your console.log(user);
Account. }}
<Alert variant="destructive" className="mt-5"> variant="destructive"
>
Delete Account
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Account</DialogTitle>
<DialogDescription>
Documenso will delete{' '}
<span className="font-semibold">all of your documents</span>, along with all
of your completed documents, signatures, and all other resources belonging
to your Account.
</DialogDescription>
</DialogHeader>
<Alert variant="destructive">
<AlertDescription className="selection:bg-red-100"> <AlertDescription className="selection:bg-red-100">
This action is not reversible. Please be certain. This action is not reversible. Please be certain.
</AlertDescription> </AlertDescription>
</Alert> </Alert>
</DialogDescription>
</DialogHeader> {hasTwoFactorAuthentication && (
<DialogFooter> <div className="flex flex-col gap-y-4">
<Button <FormField
onClick={onDeleteAccount} name="token"
loading={isDeletingAccount} control={deleteAccountTwoFactorTokenForm.control}
variant="destructive" render={({ field }) => (
> <FormItem>
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'} <FormLabel className="text-muted-foreground">
</Button> Two Factor Authentication Token
</DialogFooter> </FormLabel>
</DialogContent> <FormControl>
</Dialog> <Input {...field} value={field.value ?? ''} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>
)}
<DialogFooter>
<Button
onClick={onDeleteAccount}
loading={isDeletingAccount}
variant="destructive"
>
{isDeletingAccount ? 'Deleting account...' : 'Delete Account'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</form>
</Form>
</CardFooter> </CardFooter>
</Card> </Card>
</div> </div>

View File

@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp';
import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes';
import { prisma } from '@documenso/prisma'; import { prisma } from '@documenso/prisma';
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricEncrypt } from '../../universal/crypto'; import { symmetricEncrypt } from '../../universal/crypto';

View File

@ -1,4 +1,4 @@
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { ErrorCode } from '../../next-auth/error-codes'; import { ErrorCode } from '../../next-auth/error-codes';
import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token';

View File

@ -1,7 +1,7 @@
import { base32 } from '@scure/base'; import { base32 } from '@scure/base';
import { TOTPController } from 'oslo/otp'; import { TOTPController } from 'oslo/otp';
import { User } from '@documenso/prisma/client'; import type { User } from '@documenso/prisma/client';
import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto';
import { symmetricDecrypt } from '../../universal/crypto'; import { symmetricDecrypt } from '../../universal/crypto';
@ -17,6 +17,7 @@ export const verifyTwoFactorAuthenticationToken = async ({
user, user,
totpCode, totpCode,
}: VerifyTwoFactorAuthenticationTokenOptions) => { }: VerifyTwoFactorAuthenticationTokenOptions) => {
// TODO: This is undefined and I can't figure out why.
const key = DOCUMENSO_ENCRYPTION_KEY; const key = DOCUMENSO_ENCRYPTION_KEY;
if (!user.twoFactorSecret) { if (!user.twoFactorSecret) {

View File

@ -13,7 +13,8 @@ const buttonVariants = cva(
variants: { variants: {
variant: { variant: {
default: 'bg-primary text-primary-foreground hover:bg-primary/90', default: 'bg-primary text-primary-foreground hover:bg-primary/90',
destructive: 'bg-destructive text-destructive-foreground hover:bg-destructive/90', destructive:
'bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive',
outline: 'border border-input hover:bg-accent hover:text-accent-foreground', outline: 'border border-input hover:bg-accent hover:text-accent-foreground',
secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80',
ghost: 'hover:bg-accent hover:text-accent-foreground', ghost: 'hover:bg-accent hover:text-accent-foreground',