From 9e433af1126c2d0f7665d0cb827cd16ffe9fff91 Mon Sep 17 00:00:00 2001 From: Ephraim Atta-Duncan Date: Sun, 21 Jan 2024 15:38:32 +0000 Subject: [PATCH] feat: require 2fa code before account is deleted --- apps/web/src/components/forms/profile.tsx | 123 +++++++++++++----- packages/lib/server-only/2fa/setup-2fa.ts | 2 +- packages/lib/server-only/2fa/validate-2fa.ts | 2 +- .../lib/server-only/2fa/verify-2fa-token.ts | 3 +- packages/ui/primitives/button.tsx | 3 +- 5 files changed, 98 insertions(+), 35 deletions(-) diff --git a/apps/web/src/components/forms/profile.tsx b/apps/web/src/components/forms/profile.tsx index 7e274ff8e..575a81d46 100644 --- a/apps/web/src/components/forms/profile.tsx +++ b/apps/web/src/components/forms/profile.tsx @@ -7,6 +7,7 @@ import { signOut } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { validateTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/validate-2fa'; import type { User } from '@documenso/prisma/client'; import { TRPCClientError } from '@documenso/trpc/client'; 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'), }); +export const ZTwoFactorAuthTokenSchema = z.object({ + token: z.string(), +}); + +export type TTwoFactorAuthTokenSchema = z.infer; export type TProfileFormSchema = z.infer; export type ProfileFormProps = { @@ -61,7 +67,15 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { resolver: zodResolver(ZProfileFormSchema), }); + const deleteAccountTwoFactorTokenForm = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZTwoFactorAuthTokenSchema), + }); + const isSubmitting = form.formState.isSubmitting; + const hasTwoFactorAuthentication = user.twoFactorEnabled; const { mutateAsync: updateProfile } = trpc.profile.updateProfile.useMutation(); const { mutateAsync: deleteAccount, isLoading: isDeletingAccount } = @@ -101,9 +115,20 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { const onDeleteAccount = async () => { 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({ title: 'Account deleted', @@ -111,9 +136,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { duration: 5000, }); - // logout after deleting account - - router.push('/'); + await signOut({ callbackUrl: '/' }); } catch (err) { if (err instanceof TRPCClientError && err.data?.code === 'BAD_REQUEST') { toast({ @@ -126,6 +149,7 @@ export const ProfileForm = ({ className, user }: ProfileFormProps) => { title: 'An unknown error occurred', variant: 'destructive', description: + err.message ?? '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. - - - - - - - Delete Account - - Documenso will delete{' '} - all of your documents, along with all of - your completed documents, signatures, and all other resources belonging to your - Account. - +
+ { + console.log('delete account'); + })} + > + + + + + + + Delete Account + + Documenso will delete{' '} + all of your documents, along with all + of your completed documents, signatures, and all other resources belonging + to your Account. + + + + This action is not reversible. Please be certain. - - - - - - - + + {hasTwoFactorAuthentication && ( +
+ ( + + + Two Factor Authentication Token + + + + + + + )} + /> +
+ )} + + + + + +
+ +
diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index 30ddf0ec3..a60b0934b 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -5,7 +5,7 @@ import { createTOTPKeyURI } from 'oslo/otp'; import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; 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 { symmetricEncrypt } from '../../universal/crypto'; diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts index 7fc76a8bb..33141c325 100644 --- a/packages/lib/server-only/2fa/validate-2fa.ts +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -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 { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts index fa9159517..3c410bd58 100644 --- a/packages/lib/server-only/2fa/verify-2fa-token.ts +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -1,7 +1,7 @@ import { base32 } from '@scure/base'; 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 { symmetricDecrypt } from '../../universal/crypto'; @@ -17,6 +17,7 @@ export const verifyTwoFactorAuthenticationToken = async ({ user, totpCode, }: VerifyTwoFactorAuthenticationTokenOptions) => { + // TODO: This is undefined and I can't figure out why. const key = DOCUMENSO_ENCRYPTION_KEY; if (!user.twoFactorSecret) { diff --git a/packages/ui/primitives/button.tsx b/packages/ui/primitives/button.tsx index 5754b35a5..68ecb6eb0 100644 --- a/packages/ui/primitives/button.tsx +++ b/packages/ui/primitives/button.tsx @@ -13,7 +13,8 @@ const buttonVariants = cva( variants: { variant: { 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', secondary: 'bg-secondary text-secondary-foreground hover:bg-secondary/80', ghost: 'hover:bg-accent hover:text-accent-foreground',