From 43400c07de79ba22f324670598e4d8a0f60dd13f Mon Sep 17 00:00:00 2001 From: David Nguyen Date: Mon, 25 Mar 2024 11:34:50 +0800 Subject: [PATCH] feat: remove 2FA password requirement (#1053) --- .../(dashboard)/settings/security/page.tsx | 90 ++--- .../forms/2fa/authenticator-app.tsx | 43 --- .../2fa/disable-authenticator-app-dialog.tsx | 131 +++---- .../2fa/enable-authenticator-app-dialog.tsx | 363 +++++++----------- .../components/forms/2fa/recovery-codes.tsx | 33 -- .../forms/2fa/view-recovery-codes-dialog.tsx | 213 +++++----- packages/lib/server-only/2fa/disable-2fa.ts | 28 +- packages/lib/server-only/2fa/enable-2fa.ts | 42 +- packages/lib/server-only/2fa/setup-2fa.ts | 17 - .../lib/server-only/2fa/view-backup-codes.ts | 30 ++ .../router.ts | 87 ++--- .../schema.ts | 13 +- 12 files changed, 432 insertions(+), 658 deletions(-) delete mode 100644 apps/web/src/components/forms/2fa/authenticator-app.tsx delete mode 100644 apps/web/src/components/forms/2fa/recovery-codes.tsx create mode 100644 packages/lib/server-only/2fa/view-backup-codes.ts diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx index f46784aed..ba5d9846c 100644 --- a/apps/web/src/app/(dashboard)/settings/security/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -1,14 +1,14 @@ import type { Metadata } from 'next'; import Link from 'next/link'; -import { IDENTITY_PROVIDER_NAME } from '@documenso/lib/constants/auth'; import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; import { Alert, AlertDescription, AlertTitle } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { SettingsHeader } from '~/components/(dashboard)/settings/layout/header'; -import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; -import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; +import { DisableAuthenticatorAppDialog } from '~/components/forms/2fa/disable-authenticator-app-dialog'; +import { EnableAuthenticatorAppDialog } from '~/components/forms/2fa/enable-authenticator-app-dialog'; +import { ViewRecoveryCodesDialog } from '~/components/forms/2fa/view-recovery-codes-dialog'; import { PasswordForm } from '~/components/forms/password'; export const metadata: Metadata = { @@ -25,57 +25,51 @@ export default async function SecuritySettingsPage() { subtitle="Here you can manage your password and security settings." /> - {user.identityProvider === 'DOCUMENSO' ? ( -
+ {user.identityProvider === 'DOCUMENSO' && ( + <>
+ + )} - -
- Two factor authentication + +
+ Two factor authentication - - Create one-time passwords that serve as a secondary authentication method for - confirming your identity when requested during the sign-in process. - -
- - -
- - {user.twoFactorEnabled && ( - -
- Recovery codes - - - Two factor authentication recovery codes are used to access your account in the - event that you lose access to your authenticator app. - -
- - -
- )} -
- ) : ( - - - Your account is managed by {IDENTITY_PROVIDER_NAME[user.identityProvider]} - - - - To update your password, enable two-factor authentication, and manage other security - settings, please go to your {IDENTITY_PROVIDER_NAME[user.identityProvider]} account - settings. + + Add an authenticator to serve as a secondary authentication method{' '} + {user.identityProvider === 'DOCUMENSO' + ? 'when signing in, or when signing documents.' + : 'for signing documents.'} +
+ + {user.twoFactorEnabled ? ( + + ) : ( + + )} + + + {user.twoFactorEnabled && ( + +
+ Recovery codes + + + Two factor authentication recovery codes are used to access your account in the event + that you lose access to your authenticator app. + +
+ +
)} diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx deleted file mode 100644 index 3aa0e123e..000000000 --- a/apps/web/src/components/forms/2fa/authenticator-app.tsx +++ /dev/null @@ -1,43 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { Button } from '@documenso/ui/primitives/button'; - -import { DisableAuthenticatorAppDialog } from './disable-authenticator-app-dialog'; -import { EnableAuthenticatorAppDialog } from './enable-authenticator-app-dialog'; - -type AuthenticatorAppProps = { - isTwoFactorEnabled: boolean; -}; - -export const AuthenticatorApp = ({ isTwoFactorEnabled }: AuthenticatorAppProps) => { - const [modalState, setModalState] = useState<'enable' | 'disable' | null>(null); - - const isEnableDialogOpen = modalState === 'enable'; - const isDisableDialogOpen = modalState === 'disable'; - - return ( - <> -
- {isTwoFactorEnabled ? ( - - ) : ( - - )} -
- - !open && setModalState(null)} - /> - - !open && setModalState(null)} - /> - - ); -}; diff --git a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx index 06211170f..c5745a499 100644 --- a/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -1,3 +1,7 @@ +'use client'; + +import { useState } from 'react'; + import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; @@ -9,65 +13,51 @@ import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } 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 { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; -export const ZDisableTwoFactorAuthenticationForm = z.object({ - password: z.string().min(6).max(72), - backupCode: z.string(), +export const ZDisable2FAForm = z.object({ + token: z.string(), }); -export type TDisableTwoFactorAuthenticationForm = z.infer< - typeof ZDisableTwoFactorAuthenticationForm ->; +export type TDisable2FAForm = z.infer; -export type DisableAuthenticatorAppDialogProps = { - open: boolean; - onOpenChange: (_open: boolean) => void; -}; - -export const DisableAuthenticatorAppDialog = ({ - open, - onOpenChange, -}: DisableAuthenticatorAppDialogProps) => { +export const DisableAuthenticatorAppDialog = () => { const router = useRouter(); + const { toast } = useToast(); - const { mutateAsync: disableTwoFactorAuthentication } = - trpc.twoFactorAuthentication.disable.useMutation(); + const [isOpen, setIsOpen] = useState(false); - const disableTwoFactorAuthenticationForm = useForm({ + const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation(); + + const disable2FAForm = useForm({ defaultValues: { - password: '', - backupCode: '', + token: '', }, - resolver: zodResolver(ZDisableTwoFactorAuthenticationForm), + resolver: zodResolver(ZDisable2FAForm), }); - const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } = - disableTwoFactorAuthenticationForm.formState; + const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState; - const onDisableTwoFactorAuthenticationFormSubmit = async ({ - password, - backupCode, - }: TDisableTwoFactorAuthenticationForm) => { + const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => { try { - await disableTwoFactorAuthentication({ password, backupCode }); + await disable2FA({ token }); toast({ title: 'Two-factor authentication disabled', @@ -76,7 +66,7 @@ export const DisableAuthenticatorAppDialog = ({ }); flushSync(() => { - onOpenChange(false); + setIsOpen(false); }); router.refresh(); @@ -91,74 +81,51 @@ export const DisableAuthenticatorAppDialog = ({ }; return ( - - + + + + + + - Disable Authenticator App + Disable 2FA - To disable the Authenticator App for your account, please enter your password and a - backup code. If you do not have a backup code available, please contact support. + Please provide a token from the authenticator, or a backup code. If you do not have a + backup code available, please contact support. -
- -
+ + +
( - Password - + )} /> - ( - - Backup Code - - - - - - )} - /> + + + + + + +
- - - - - - diff --git a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx index d7a8f6553..0a6aac5dc 100644 --- a/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -1,8 +1,11 @@ -import { useEffect, useMemo } from 'react'; +'use client'; + +import { useEffect, useState } from 'react'; + +import { useRouter } from 'next/navigation'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { match } from 'ts-pattern'; import { renderSVG } from 'uqr'; import { z } from 'zod'; @@ -11,11 +14,13 @@ import { trpc } from '@documenso/trpc/react'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from '@documenso/ui/primitives/dialog'; import { Form, @@ -26,85 +31,60 @@ import { FormMessage, } from '@documenso/ui/primitives/form/form'; import { Input } from '@documenso/ui/primitives/input'; -import { PasswordInput } from '@documenso/ui/primitives/password-input'; import { useToast } from '@documenso/ui/primitives/use-toast'; import { RecoveryCodeList } from './recovery-code-list'; -export const ZSetupTwoFactorAuthenticationForm = z.object({ - password: z.string().min(6).max(72), -}); - -export type TSetupTwoFactorAuthenticationForm = z.infer; - -export const ZEnableTwoFactorAuthenticationForm = z.object({ +export const ZEnable2FAForm = z.object({ token: z.string(), }); -export type TEnableTwoFactorAuthenticationForm = z.infer; +export type TEnable2FAForm = z.infer; -export type EnableAuthenticatorAppDialogProps = { - open: boolean; - onOpenChange: (_open: boolean) => void; -}; - -export const EnableAuthenticatorAppDialog = ({ - open, - onOpenChange, -}: EnableAuthenticatorAppDialogProps) => { +export const EnableAuthenticatorAppDialog = () => { const { toast } = useToast(); + const router = useRouter(); - const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = - trpc.twoFactorAuthentication.setup.useMutation(); + const [isOpen, setIsOpen] = useState(false); + const [recoveryCodes, setRecoveryCodes] = useState(null); + + const { mutateAsync: enable2FA } = trpc.twoFactorAuthentication.enable.useMutation(); const { - mutateAsync: enableTwoFactorAuthentication, - data: enableTwoFactorAuthenticationData, - isLoading: isEnableTwoFactorAuthenticationDataLoading, - } = trpc.twoFactorAuthentication.enable.useMutation(); - - const setupTwoFactorAuthenticationForm = useForm({ - defaultValues: { - password: '', + mutateAsync: setup2FA, + data: setup2FAData, + isLoading: isSettingUp2FA, + } = trpc.twoFactorAuthentication.setup.useMutation({ + onError: () => { + toast({ + title: 'Unable to setup two-factor authentication', + description: + 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your code correctly and try again.', + variant: 'destructive', + }); }, - resolver: zodResolver(ZSetupTwoFactorAuthenticationForm), }); - const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } = - setupTwoFactorAuthenticationForm.formState; - - const enableTwoFactorAuthenticationForm = useForm({ + const enable2FAForm = useForm({ defaultValues: { token: '', }, - resolver: zodResolver(ZEnableTwoFactorAuthenticationForm), + resolver: zodResolver(ZEnable2FAForm), }); - const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } = - enableTwoFactorAuthenticationForm.formState; + const { isSubmitting: isEnabling2FA } = enable2FAForm.formState; - const step = useMemo(() => { - if (!setupTwoFactorAuthenticationData || isSetupTwoFactorAuthenticationSubmitting) { - return 'setup'; - } - - if (!enableTwoFactorAuthenticationData || isEnableTwoFactorAuthenticationSubmitting) { - return 'enable'; - } - - return 'view'; - }, [ - setupTwoFactorAuthenticationData, - isSetupTwoFactorAuthenticationSubmitting, - enableTwoFactorAuthenticationData, - isEnableTwoFactorAuthenticationSubmitting, - ]); - - const onSetupTwoFactorAuthenticationFormSubmit = async ({ - password, - }: TSetupTwoFactorAuthenticationForm) => { + const onEnable2FAFormSubmit = async ({ token }: TEnable2FAForm) => { try { - await setupTwoFactorAuthentication({ password }); + const data = await enable2FA({ code: token }); + + setRecoveryCodes(data.recoveryCodes); + + toast({ + title: 'Two-factor authentication enabled', + description: + 'You will now be required to enter a code from your authenticator app when signing in.', + }); } catch (_err) { toast({ title: 'Unable to setup two-factor authentication', @@ -116,8 +96,8 @@ export const EnableAuthenticatorAppDialog = ({ }; const downloadRecoveryCodes = () => { - if (enableTwoFactorAuthenticationData && enableTwoFactorAuthenticationData.recoveryCodes) { - const blob = new Blob([enableTwoFactorAuthenticationData.recoveryCodes.join('\n')], { + if (recoveryCodes) { + const blob = new Blob([recoveryCodes.join('\n')], { type: 'text/plain', }); @@ -128,175 +108,126 @@ export const EnableAuthenticatorAppDialog = ({ } }; - const onEnableTwoFactorAuthenticationFormSubmit = async ({ - token, - }: TEnableTwoFactorAuthenticationForm) => { - try { - await enableTwoFactorAuthentication({ code: token }); - - toast({ - title: 'Two-factor authentication enabled', - description: - 'Two-factor authentication has been enabled for your account. You will now be required to enter a code from your authenticator app when signing in.', - }); - } catch (_err) { - toast({ - title: 'Unable to setup two-factor authentication', - description: - 'We were unable to setup two-factor authentication for your account. Please ensure that you have entered your password correctly and try again.', - variant: 'destructive', - }); + const handleEnable2FA = async () => { + if (!setup2FAData) { + await setup2FA(); } + + setIsOpen(true); }; useEffect(() => { - // Reset the form when the Dialog closes - if (!open) { - setupTwoFactorAuthenticationForm.reset(); + enable2FAForm.reset(); + + if (!isOpen && recoveryCodes && recoveryCodes.length > 0) { + setRecoveryCodes(null); + router.refresh(); } - }, [open, setupTwoFactorAuthenticationForm]); + + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isOpen]); return ( - - - - Enable Authenticator App + + + + - {step === 'setup' && ( - - To enable two-factor authentication, please enter your password below. - - )} + + {setup2FAData && ( + <> + {recoveryCodes ? ( +
+ + Backup codes + + Your recovery codes are listed below. Please store them in a safe place. + + - {step === 'view' && ( - - Your recovery codes are listed below. Please store them in a safe place. - - )} - +
+ +
- {match(step) - .with('setup', () => { - return ( -
- - ( - - Password - - - - - - )} - /> + + + + - - + + +
+ ) : ( + + + + Enable Authenticator App + + To enable two-factor authentication, scan the following QR code using your + authenticator app. + + - - +
+
+ +

+ If your authenticator app does not support QR codes, you can use the following + code instead: +

+ +

+ {setup2FAData?.secret} +

+ +

+ Once you have scanned the QR code or entered the code manually, enter the code + provided by your authenticator app below. +

+ + ( + + Token + + + + + + )} + /> + + + + + + + + +
- ); - }) - .with('enable', () => ( -
- -

- To enable two-factor authentication, scan the following QR code using your - authenticator app. -

- -
- -

- If your authenticator app does not support QR codes, you can use the following - code instead: -

- -

- {setupTwoFactorAuthenticationData?.secret} -

- -

- Once you have scanned the QR code or entered the code manually, enter the code - provided by your authenticator app below. -

- - ( - - Token - - - - - - )} - /> - - - - - - - - - )) - .with('view', () => ( -
- {enableTwoFactorAuthenticationData?.recoveryCodes && ( - - )} - -
- - - -
-
- )) - .exhaustive()} + )} + + )}
); diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx deleted file mode 100644 index 29834c74a..000000000 --- a/apps/web/src/components/forms/2fa/recovery-codes.tsx +++ /dev/null @@ -1,33 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -import { Button } from '@documenso/ui/primitives/button'; - -import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog'; - -type RecoveryCodesProps = { - isTwoFactorEnabled: boolean; -}; - -export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => { - const [isOpen, setIsOpen] = useState(false); - - return ( - <> - - - - - ); -}; diff --git a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx index 48e343e8d..66df7bbab 100644 --- a/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -1,4 +1,6 @@ -import { useEffect, useMemo } from 'react'; +'use client'; + +import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -6,69 +8,61 @@ import { match } from 'ts-pattern'; import { z } from 'zod'; import { downloadFile } from '@documenso/lib/client-only/download-file'; +import { AppError } from '@documenso/lib/errors/app-error'; +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { trpc } from '@documenso/trpc/react'; +import { Alert, AlertDescription } from '@documenso/ui/primitives/alert'; import { Button } from '@documenso/ui/primitives/button'; import { Dialog, + DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, + DialogTrigger, } from '@documenso/ui/primitives/dialog'; import { Form, FormControl, FormField, FormItem, - FormLabel, FormMessage, } from '@documenso/ui/primitives/form/form'; -import { PasswordInput } from '@documenso/ui/primitives/password-input'; -import { useToast } from '@documenso/ui/primitives/use-toast'; +import { Input } from '@documenso/ui/primitives/input'; import { RecoveryCodeList } from './recovery-code-list'; export const ZViewRecoveryCodesForm = z.object({ - password: z.string().min(6).max(72), + token: z.string().min(1, { message: 'Token is required' }), }); export type TViewRecoveryCodesForm = z.infer; -export type ViewRecoveryCodesDialogProps = { - open: boolean; - onOpenChange: (_open: boolean) => void; -}; - -export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { - const { toast } = useToast(); +export const ViewRecoveryCodesDialog = () => { + const [isOpen, setIsOpen] = useState(false); const { - mutateAsync: viewRecoveryCodes, - data: viewRecoveryCodesData, - isLoading: isViewRecoveryCodesDataLoading, + data: recoveryCodes, + mutate, + isLoading, + isError, + error, } = trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); + // error?.data?.code + const viewRecoveryCodesForm = useForm({ defaultValues: { - password: '', + token: '', }, resolver: zodResolver(ZViewRecoveryCodesForm), }); - const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState; - - const step = useMemo(() => { - if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) { - return 'authenticate'; - } - - return 'view'; - }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); - const downloadRecoveryCodes = () => { - if (viewRecoveryCodesData && viewRecoveryCodesData.recoveryCodes) { - const blob = new Blob([viewRecoveryCodesData.recoveryCodes.join('\n')], { + if (recoveryCodes) { + const blob = new Blob([recoveryCodes.join('\n')], { type: 'text/plain', }); @@ -79,105 +73,88 @@ export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCode } }; - const onViewRecoveryCodesFormSubmit = async ({ password }: TViewRecoveryCodesForm) => { - try { - await viewRecoveryCodes({ password }); - } catch (_err) { - toast({ - title: 'Unable to view recovery codes', - description: - 'We were unable to view your recovery codes. Please ensure that you have entered your password correctly and try again.', - variant: 'destructive', - }); - } - }; - - useEffect(() => { - // Reset the form when the Dialog closes - if (!open) { - viewRecoveryCodesForm.reset(); - } - }, [open, viewRecoveryCodesForm]); - return ( - + + + + + - - View Recovery Codes + {recoveryCodes ? ( +
+ + View Recovery Codes - {step === 'authenticate' && ( - - To view your recovery codes, please enter your password below. - - )} + + Your recovery codes are listed below. Please store them in a safe place. + + - {step === 'view' && ( - - Your recovery codes are listed below. Please store them in a safe place. - - )} - + - {match(step) - .with('authenticate', () => { - return ( -
- - ( - - Password - - - - - - )} - /> + + + + - - + +
+ ) : ( + + mutate(value))}> + + View Recovery Codes + + + Please provide a token from your authenticator, or a backup code. + + + +
+ ( + + + + + + + )} + /> + + {error && ( + + + {match(AppError.parseError(error).message) + .with( + ErrorCode.INCORRECT_TWO_FACTOR_CODE, + () => 'Invalid code. Please try again.', + ) + .otherwise( + () => 'Something went wrong. Please try again or contact support.', + )} + + + )} + + + + + - - - - - ); - }) - .with('view', () => ( -
- {viewRecoveryCodesData?.recoveryCodes && ( - - )} - -
- - - -
-
- )) - .exhaustive()} + + +
+ + + )}
); diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts index aba763d2d..7d77ce363 100644 --- a/packages/lib/server-only/2fa/disable-2fa.ts +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -1,40 +1,30 @@ -import { compare } from '@node-rs/bcrypt'; - import { prisma } from '@documenso/prisma'; import type { User } from '@documenso/prisma/client'; import { UserSecurityAuditLogType } from '@documenso/prisma/client'; -import { ErrorCode } from '../../next-auth/error-codes'; +import { AppError } from '../../errors/app-error'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { validateTwoFactorAuthentication } from './validate-2fa'; type DisableTwoFactorAuthenticationOptions = { user: User; - backupCode: string; - password: string; + token: string; requestMetadata?: RequestMetadata; }; export const disableTwoFactorAuthentication = async ({ - backupCode, + token, user, - password, requestMetadata, }: DisableTwoFactorAuthenticationOptions) => { - if (!user.password) { - throw new Error(ErrorCode.USER_MISSING_PASSWORD); - } - - const isCorrectPassword = await compare(password, user.password); - - if (!isCorrectPassword) { - throw new Error(ErrorCode.INCORRECT_PASSWORD); - } - - const isValid = await validateTwoFactorAuthentication({ backupCode, user }); + let isValid = await validateTwoFactorAuthentication({ totpCode: token, user }); if (!isValid) { - throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE); + isValid = await validateTwoFactorAuthentication({ backupCode: token, user }); + } + + if (!isValid) { + throw new AppError('INCORRECT_TWO_FACTOR_CODE'); } await prisma.$transaction(async (tx) => { diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts index 19a2b67c2..cf13c59e6 100644 --- a/packages/lib/server-only/2fa/enable-2fa.ts +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -1,7 +1,7 @@ -import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; import { prisma } from '@documenso/prisma'; import { type User, UserSecurityAuditLogType } from '@documenso/prisma/client'; +import { AppError } from '../../errors/app-error'; import type { RequestMetadata } from '../../universal/extract-request-metadata'; import { getBackupCodes } from './get-backup-code'; import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; @@ -17,25 +17,38 @@ export const enableTwoFactorAuthentication = async ({ code, requestMetadata, }: EnableTwoFactorAuthenticationOptions) => { - if (user.identityProvider !== 'DOCUMENSO') { - throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER); - } - if (user.twoFactorEnabled) { - throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED); + throw new AppError('TWO_FACTOR_ALREADY_ENABLED'); } if (!user.twoFactorSecret) { - throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED); + throw new AppError('TWO_FACTOR_SETUP_REQUIRED'); } const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code }); if (!isValidToken) { - throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE); + throw new AppError('INCORRECT_TWO_FACTOR_CODE'); } - const updatedUser = await prisma.$transaction(async (tx) => { + let recoveryCodes: string[] = []; + + await prisma.$transaction(async (tx) => { + const updatedUser = await tx.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: true, + }, + }); + + recoveryCodes = getBackupCodes({ user: updatedUser }) ?? []; + + if (recoveryCodes.length === 0) { + throw new AppError('MISSING_BACKUP_CODE'); + } + await tx.userSecurityAuditLog.create({ data: { userId: user.id, @@ -44,18 +57,7 @@ export const enableTwoFactorAuthentication = async ({ ipAddress: requestMetadata?.ipAddress, }, }); - - return await tx.user.update({ - where: { - id: user.id, - }, - data: { - twoFactorEnabled: true, - }, - }); }); - const recoveryCodes = getBackupCodes({ user: updatedUser }); - return { recoveryCodes }; }; diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts index e94676121..cc08510d0 100644 --- a/packages/lib/server-only/2fa/setup-2fa.ts +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -1,4 +1,3 @@ -import { compare } from '@node-rs/bcrypt'; import { base32 } from '@scure/base'; import crypto from 'crypto'; import { createTOTPKeyURI } from 'oslo/otp'; @@ -12,14 +11,12 @@ import { symmetricEncrypt } from '../../universal/crypto'; type SetupTwoFactorAuthenticationOptions = { user: User; - password: string; }; const ISSUER = 'Documenso'; export const setupTwoFactorAuthentication = async ({ user, - password, }: SetupTwoFactorAuthenticationOptions) => { const key = DOCUMENSO_ENCRYPTION_KEY; @@ -27,20 +24,6 @@ export const setupTwoFactorAuthentication = async ({ throw new Error(ErrorCode.MISSING_ENCRYPTION_KEY); } - if (user.identityProvider !== 'DOCUMENSO') { - throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER); - } - - if (!user.password) { - throw new Error(ErrorCode.USER_MISSING_PASSWORD); - } - - const isCorrectPassword = await compare(password, user.password); - - if (!isCorrectPassword) { - throw new Error(ErrorCode.INCORRECT_PASSWORD); - } - const secret = crypto.randomBytes(10); const backupCodes = Array.from({ length: 10 }) diff --git a/packages/lib/server-only/2fa/view-backup-codes.ts b/packages/lib/server-only/2fa/view-backup-codes.ts new file mode 100644 index 000000000..a57253116 --- /dev/null +++ b/packages/lib/server-only/2fa/view-backup-codes.ts @@ -0,0 +1,30 @@ +import type { User } from '@documenso/prisma/client'; + +import { AppError } from '../../errors/app-error'; +import { getBackupCodes } from './get-backup-code'; +import { validateTwoFactorAuthentication } from './validate-2fa'; + +type ViewBackupCodesOptions = { + user: User; + token: string; +}; + +export const viewBackupCodes = async ({ token, user }: ViewBackupCodesOptions) => { + let isValid = await validateTwoFactorAuthentication({ totpCode: token, user }); + + if (!isValid) { + isValid = await validateTwoFactorAuthentication({ backupCode: token, user }); + } + + if (!isValid) { + throw new AppError('INCORRECT_TWO_FACTOR_CODE'); + } + + const backupCodes = getBackupCodes({ user }); + + if (!backupCodes) { + throw new AppError('MISSING_BACKUP_CODE'); + } + + return backupCodes; +}; diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts index 36fe93a60..929dc3f73 100644 --- a/packages/trpc/server/two-factor-authentication-router/router.ts +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -1,34 +1,34 @@ import { TRPCError } from '@trpc/server'; -import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { AppError } from '@documenso/lib/errors/app-error'; import { disableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/disable-2fa'; import { enableTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/enable-2fa'; -import { getBackupCodes } from '@documenso/lib/server-only/2fa/get-backup-code'; import { setupTwoFactorAuthentication } from '@documenso/lib/server-only/2fa/setup-2fa'; -import { compareSync } from '@documenso/lib/server-only/auth/hash'; +import { viewBackupCodes } from '@documenso/lib/server-only/2fa/view-backup-codes'; import { extractNextApiRequestMetadata } from '@documenso/lib/universal/extract-request-metadata'; import { authenticatedProcedure, router } from '../trpc'; import { ZDisableTwoFactorAuthenticationMutationSchema, ZEnableTwoFactorAuthenticationMutationSchema, - ZSetupTwoFactorAuthenticationMutationSchema, ZViewRecoveryCodesMutationSchema, } from './schema'; export const twoFactorAuthenticationRouter = router({ - setup: authenticatedProcedure - .input(ZSetupTwoFactorAuthenticationMutationSchema) - .mutation(async ({ ctx, input }) => { - const user = ctx.user; - - const { password } = input; - + setup: authenticatedProcedure.mutation(async ({ ctx }) => { + try { return await setupTwoFactorAuthentication({ - user, - password, + user: ctx.user, }); - }), + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to setup two-factor authentication. Please try again later.', + }); + } + }), enable: authenticatedProcedure .input(ZEnableTwoFactorAuthenticationMutationSchema) @@ -44,7 +44,11 @@ export const twoFactorAuthenticationRouter = router({ requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { - console.error(err); + const error = AppError.parseError(err); + + if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') { + console.error(err); + } throw new TRPCError({ code: 'BAD_REQUEST', @@ -59,16 +63,17 @@ export const twoFactorAuthenticationRouter = router({ try { const user = ctx.user; - const { password, backupCode } = input; - return await disableTwoFactorAuthentication({ user, - password, - backupCode, + token: input.token, requestMetadata: extractNextApiRequestMetadata(ctx.req), }); } catch (err) { - console.error(err); + const error = AppError.parseError(err); + + if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') { + console.error(err); + } throw new TRPCError({ code: 'BAD_REQUEST', @@ -81,38 +86,18 @@ export const twoFactorAuthenticationRouter = router({ .input(ZViewRecoveryCodesMutationSchema) .mutation(async ({ ctx, input }) => { try { - const user = ctx.user; - - const { password } = input; - - if (!user.twoFactorEnabled) { - throw new TRPCError({ - code: 'BAD_REQUEST', - message: ErrorCode.TWO_FACTOR_SETUP_REQUIRED, - }); - } - - if (!user.password || !compareSync(password, user.password)) { - throw new TRPCError({ - code: 'UNAUTHORIZED', - message: ErrorCode.INCORRECT_PASSWORD, - }); - } - - const recoveryCodes = await getBackupCodes({ user }); - - return { recoveryCodes }; - } catch (err) { - console.error(err); - - if (err instanceof TRPCError) { - throw err; - } - - throw new TRPCError({ - code: 'BAD_REQUEST', - message: 'We were unable to view your recovery codes. Please try again later.', + return await viewBackupCodes({ + user: ctx.user, + token: input.token, }); + } catch (err) { + const error = AppError.parseError(err); + + if (error.code !== 'INCORRECT_TWO_FACTOR_CODE') { + console.error(err); + } + + throw AppError.parseErrorToTRPCError(err); } }), }); diff --git a/packages/trpc/server/two-factor-authentication-router/schema.ts b/packages/trpc/server/two-factor-authentication-router/schema.ts index 3a831845f..befa8a4b1 100644 --- a/packages/trpc/server/two-factor-authentication-router/schema.ts +++ b/packages/trpc/server/two-factor-authentication-router/schema.ts @@ -1,13 +1,5 @@ import { z } from 'zod'; -export const ZSetupTwoFactorAuthenticationMutationSchema = z.object({ - password: z.string().min(1), -}); - -export type TSetupTwoFactorAuthenticationMutationSchema = z.infer< - typeof ZSetupTwoFactorAuthenticationMutationSchema ->; - export const ZEnableTwoFactorAuthenticationMutationSchema = z.object({ code: z.string().min(6).max(6), }); @@ -17,8 +9,7 @@ export type TEnableTwoFactorAuthenticationMutationSchema = z.infer< >; export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({ - password: z.string().min(6).max(72), - backupCode: z.string().trim(), + token: z.string().trim().min(1), }); export type TDisableTwoFactorAuthenticationMutationSchema = z.infer< @@ -26,7 +17,7 @@ export type TDisableTwoFactorAuthenticationMutationSchema = z.infer< >; export const ZViewRecoveryCodesMutationSchema = z.object({ - password: z.string().min(6).max(72), + token: z.string().trim().min(1), }); export type TViewRecoveryCodesMutationSchema = z.infer;