From 792158c2cba1aecb6357448ccd48214adeebf71c Mon Sep 17 00:00:00 2001 From: Nafees Nazik <84864519+G3root@users.noreply.github.com> Date: Fri, 1 Dec 2023 05:52:16 +0530 Subject: [PATCH] feat: add two factor auth (#643) Add two factor authentication for users who wish to enhance the security of their accounts. --- .env.example | 5 + apps/web/package.json | 1 + .../app/(dashboard)/settings/billing/page.tsx | 2 +- .../(dashboard)/settings/password/page.tsx | 20 +- .../app/(dashboard)/settings/profile/page.tsx | 2 +- .../(dashboard)/settings/security/page.tsx | 46 ++ .../(dashboard)/layout/profile-dropdown.tsx | 8 +- .../settings/layout/desktop-nav.tsx | 10 +- .../settings/layout/mobile-nav.tsx | 10 +- .../forms/2fa/authenticator-app.tsx | 58 +++ .../2fa/disable-authenticator-app-dialog.tsx | 161 ++++++ .../2fa/enable-authenticator-app-dialog.tsx | 283 +++++++++++ .../forms/2fa/recovery-code-list.tsx | 57 +++ .../components/forms/2fa/recovery-codes.tsx | 43 ++ .../forms/2fa/view-recovery-codes-dialog.tsx | 151 ++++++ apps/web/src/components/forms/signin.tsx | 167 ++++-- package-lock.json | 480 +++++++++++++++++- packages/lib/constants/crypto.ts | 1 + packages/lib/next-auth/auth-options.ts | 24 +- packages/lib/next-auth/error-codes.ts | 11 + packages/lib/package.json | 3 + packages/lib/server-only/2fa/disable-2fa.ts | 48 ++ packages/lib/server-only/2fa/enable-2fa.ts | 47 ++ .../lib/server-only/2fa/get-backup-code.ts | 38 ++ .../lib/server-only/2fa/is-2fa-availble.ts | 17 + packages/lib/server-only/2fa/setup-2fa.ts | 76 +++ packages/lib/server-only/2fa/validate-2fa.ts | 35 ++ .../lib/server-only/2fa/verify-2fa-token.ts | 33 ++ .../lib/server-only/2fa/verify-backup-code.ts | 18 + packages/lib/server-only/auth/hash.ts | 6 +- packages/lib/universal/crypto.ts | 32 ++ .../20231105184518_add_2fa/migration.sql | 4 + packages/prisma/schema.prisma | 41 +- packages/trpc/package.json | 3 +- packages/trpc/server/auth-router/router.ts | 25 +- packages/trpc/server/auth-router/schema.ts | 2 + packages/trpc/server/router.ts | 2 + .../router.ts | 105 ++++ .../schema.ts | 32 ++ packages/tsconfig/process-env.d.ts | 1 + packages/ui/primitives/input.tsx | 39 +- turbo.json | 1 + 42 files changed, 2056 insertions(+), 92 deletions(-) create mode 100644 apps/web/src/app/(dashboard)/settings/security/page.tsx create mode 100644 apps/web/src/components/forms/2fa/authenticator-app.tsx create mode 100644 apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx create mode 100644 apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx create mode 100644 apps/web/src/components/forms/2fa/recovery-code-list.tsx create mode 100644 apps/web/src/components/forms/2fa/recovery-codes.tsx create mode 100644 apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx create mode 100644 packages/lib/constants/crypto.ts create mode 100644 packages/lib/server-only/2fa/disable-2fa.ts create mode 100644 packages/lib/server-only/2fa/enable-2fa.ts create mode 100644 packages/lib/server-only/2fa/get-backup-code.ts create mode 100644 packages/lib/server-only/2fa/is-2fa-availble.ts create mode 100644 packages/lib/server-only/2fa/setup-2fa.ts create mode 100644 packages/lib/server-only/2fa/validate-2fa.ts create mode 100644 packages/lib/server-only/2fa/verify-2fa-token.ts create mode 100644 packages/lib/server-only/2fa/verify-backup-code.ts create mode 100644 packages/lib/universal/crypto.ts create mode 100644 packages/prisma/migrations/20231105184518_add_2fa/migration.sql create mode 100644 packages/trpc/server/two-factor-authentication-router/router.ts create mode 100644 packages/trpc/server/two-factor-authentication-router/schema.ts diff --git a/.env.example b/.env.example index 7bd71c04b..45c26f6be 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,11 @@ NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_SECRET="secret" +# [[CRYPTO]] +# Application Key for symmetric encryption and decryption +# This should be a random string of at least 32 characters +NEXT_PRIVATE_ENCRYPTION_KEY="CAFEBABE" + # [[AUTH OPTIONAL]] NEXT_PRIVATE_GOOGLE_CLIENT_ID="" NEXT_PRIVATE_GOOGLE_CLIENT_SECRET="" diff --git a/apps/web/package.json b/apps/web/package.json index aed5aef06..b5bb1c218 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -44,6 +44,7 @@ "sharp": "0.32.5", "ts-pattern": "^5.0.5", "typescript": "5.2.2", + "uqr": "^0.1.2", "zod": "^3.22.4" }, "devDependencies": { diff --git a/apps/web/src/app/(dashboard)/settings/billing/page.tsx b/apps/web/src/app/(dashboard)/settings/billing/page.tsx index c7161f4ae..61dff3216 100644 --- a/apps/web/src/app/(dashboard)/settings/billing/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/billing/page.tsx @@ -41,7 +41,7 @@ export default async function BillingSettingsPage() { return (
-

Billing

+

Billing

{isMissingOrInactiveOrFreePlan && ( diff --git a/apps/web/src/app/(dashboard)/settings/password/page.tsx b/apps/web/src/app/(dashboard)/settings/password/page.tsx index 701335180..dd344a1d1 100644 --- a/apps/web/src/app/(dashboard)/settings/password/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/password/page.tsx @@ -1,19 +1,5 @@ -import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; +import { redirect } from 'next/navigation'; -import { PasswordForm } from '~/components/forms/password'; - -export default async function PasswordSettingsPage() { - const { user } = await getRequiredServerComponentSession(); - - return ( -
-

Password

- -

Here you can update your password.

- -
- - -
- ); +export default function PasswordSettingsPage() { + redirect('/settings/security'); } diff --git a/apps/web/src/app/(dashboard)/settings/profile/page.tsx b/apps/web/src/app/(dashboard)/settings/profile/page.tsx index b577ec93e..cb64fb9cd 100644 --- a/apps/web/src/app/(dashboard)/settings/profile/page.tsx +++ b/apps/web/src/app/(dashboard)/settings/profile/page.tsx @@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() { return (
-

Profile

+

Profile

Here you can edit your personal details.

diff --git a/apps/web/src/app/(dashboard)/settings/security/page.tsx b/apps/web/src/app/(dashboard)/settings/security/page.tsx new file mode 100644 index 000000000..9e99b73e8 --- /dev/null +++ b/apps/web/src/app/(dashboard)/settings/security/page.tsx @@ -0,0 +1,46 @@ +import { getRequiredServerComponentSession } from '@documenso/lib/next-auth/get-server-component-session'; + +import { AuthenticatorApp } from '~/components/forms/2fa/authenticator-app'; +import { RecoveryCodes } from '~/components/forms/2fa/recovery-codes'; +import { PasswordForm } from '~/components/forms/password'; + +export default async function SecuritySettingsPage() { + const { user } = await getRequiredServerComponentSession(); + + return ( +
+

Security

+ +

+ Here you can manage your password and security settings. +

+ +
+ + + +
+ +

Two Factor Authentication

+ +

+ Add and manage your two factor security settings to add an extra layer of security to your + account! +

+ +
+
Two-factor methods
+ + +
+ + {user.twoFactorEnabled && ( +
+
Recovery methods
+ + +
+ )} +
+ ); +} diff --git a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx index d699dea4b..99761a0d3 100644 --- a/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx +++ b/apps/web/src/components/(dashboard)/layout/profile-dropdown.tsx @@ -4,7 +4,7 @@ import Link from 'next/link'; import { CreditCard, - Key, + Lock, LogOut, User as LucideUser, Monitor, @@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => { - - - Password + + + Security diff --git a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx index 901c6a5ae..f4b2aae5e 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/desktop-nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Key, User } from 'lucide-react'; +import { CreditCard, Lock, User } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => { - + diff --git a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx index ffe2b0d80..28ffc960f 100644 --- a/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx +++ b/apps/web/src/components/(dashboard)/settings/layout/mobile-nav.tsx @@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; -import { CreditCard, Key, User } from 'lucide-react'; +import { CreditCard, Lock, User } from 'lucide-react'; import { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag'; import { cn } from '@documenso/ui/lib/utils'; @@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => { - + diff --git a/apps/web/src/components/forms/2fa/authenticator-app.tsx b/apps/web/src/components/forms/2fa/authenticator-app.tsx new file mode 100644 index 000000000..1d164bd22 --- /dev/null +++ b/apps/web/src/components/forms/2fa/authenticator-app.tsx @@ -0,0 +1,58 @@ +'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 ( + <> +
+
+

Authenticator app

+ +

+ Create one-time passwords that serve as a secondary authentication method for confirming + your identity when requested during the sign-in process. +

+
+ +
+ {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 new file mode 100644 index 000000000..eac574181 --- /dev/null +++ b/apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx @@ -0,0 +1,161 @@ +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { flushSync } from 'react-dom'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} 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 { useToast } from '@documenso/ui/primitives/use-toast'; + +export const ZDisableTwoFactorAuthenticationForm = z.object({ + password: z.string().min(6).max(72), + backupCode: z.string(), +}); + +export type TDisableTwoFactorAuthenticationForm = z.infer< + typeof ZDisableTwoFactorAuthenticationForm +>; + +export type DisableAuthenticatorAppDialogProps = { + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const DisableAuthenticatorAppDialog = ({ + open, + onOpenChange, +}: DisableAuthenticatorAppDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + + const { mutateAsync: disableTwoFactorAuthentication } = + trpc.twoFactorAuthentication.disable.useMutation(); + + const disableTwoFactorAuthenticationForm = useForm({ + defaultValues: { + password: '', + backupCode: '', + }, + resolver: zodResolver(ZDisableTwoFactorAuthenticationForm), + }); + + const { isSubmitting: isDisableTwoFactorAuthenticationSubmitting } = + disableTwoFactorAuthenticationForm.formState; + + const onDisableTwoFactorAuthenticationFormSubmit = async ({ + password, + backupCode, + }: TDisableTwoFactorAuthenticationForm) => { + try { + await disableTwoFactorAuthentication({ password, backupCode }); + + toast({ + title: 'Two-factor authentication disabled', + description: + 'Two-factor authentication has been disabled for your account. You will no longer be required to enter a code from your authenticator app when signing in.', + }); + + flushSync(() => { + onOpenChange(false); + }); + + router.refresh(); + } catch (_err) { + toast({ + title: 'Unable to disable two-factor authentication', + description: + 'We were unable to disable two-factor authentication for your account. Please ensure that you have entered your password and backup code correctly and try again.', + variant: 'destructive', + }); + } + }; + + return ( + + + + Disable Authenticator App + + + 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. + + + +
+ + ( + + 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 new file mode 100644 index 000000000..8bf835ef5 --- /dev/null +++ b/apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx @@ -0,0 +1,283 @@ +import { useMemo } from 'react'; + +import { useRouter } from 'next/navigation'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { flushSync } from 'react-dom'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { renderSVG } from 'uqr'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} 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 { 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({ + token: z.string(), +}); + +export type TEnableTwoFactorAuthenticationForm = z.infer; + +export type EnableAuthenticatorAppDialogProps = { + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const EnableAuthenticatorAppDialog = ({ + open, + onOpenChange, +}: EnableAuthenticatorAppDialogProps) => { + const router = useRouter(); + const { toast } = useToast(); + + const { mutateAsync: setupTwoFactorAuthentication, data: setupTwoFactorAuthenticationData } = + trpc.twoFactorAuthentication.setup.useMutation(); + + const { mutateAsync: enableTwoFactorAuthentication, data: enableTwoFactorAuthenticationData } = + trpc.twoFactorAuthentication.enable.useMutation(); + + const setupTwoFactorAuthenticationForm = useForm({ + defaultValues: { + password: '', + }, + resolver: zodResolver(ZSetupTwoFactorAuthenticationForm), + }); + + const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } = + setupTwoFactorAuthenticationForm.formState; + + const enableTwoFactorAuthenticationForm = useForm({ + defaultValues: { + token: '', + }, + resolver: zodResolver(ZEnableTwoFactorAuthenticationForm), + }); + + const { isSubmitting: isEnableTwoFactorAuthenticationSubmitting } = + enableTwoFactorAuthenticationForm.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) => { + try { + await setupTwoFactorAuthentication({ password }); + } 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 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 onCompleteClick = () => { + flushSync(() => { + onOpenChange(false); + }); + + router.refresh(); + }; + + return ( + + + + Enable Authenticator App + + {step === 'setup' && ( + + To enable two-factor authentication, please enter your password below. + + )} + + {step === 'view' && ( + + Your recovery codes are listed below. Please store them in a safe place. + + )} + + + {match(step) + .with('setup', () => { + return ( +
+ + ( + + Password + + + + + + )} + /> + +
+ + + +
+ + + ); + }) + .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-code-list.tsx b/apps/web/src/components/forms/2fa/recovery-code-list.tsx new file mode 100644 index 000000000..d2efb0b4b --- /dev/null +++ b/apps/web/src/components/forms/2fa/recovery-code-list.tsx @@ -0,0 +1,57 @@ +import { Copy } from 'lucide-react'; + +import { useCopyToClipboard } from '@documenso/lib/client-only/hooks/use-copy-to-clipboard'; +import { useToast } from '@documenso/ui/primitives/use-toast'; + +export type RecoveryCodeListProps = { + recoveryCodes: string[]; +}; + +export const RecoveryCodeList = ({ recoveryCodes }: RecoveryCodeListProps) => { + const { toast } = useToast(); + const [, copyToClipboard] = useCopyToClipboard(); + + const onCopyRecoveryCodeClick = async (code: string) => { + try { + const result = await copyToClipboard(code); + + if (!result) { + throw new Error('Unable to copy recovery code'); + } + + toast({ + title: 'Recovery code copied', + description: 'Your recovery code has been copied to your clipboard.', + }); + } catch (_err) { + toast({ + title: 'Unable to copy recovery code', + description: + 'We were unable to copy your recovery code to your clipboard. Please try again.', + variant: 'destructive', + }); + } + }; + + return ( +
+ {recoveryCodes.map((code) => ( +
+ {code} + +
+ +
+
+ ))} +
+ ); +}; diff --git a/apps/web/src/components/forms/2fa/recovery-codes.tsx b/apps/web/src/components/forms/2fa/recovery-codes.tsx new file mode 100644 index 000000000..7e8950227 --- /dev/null +++ b/apps/web/src/components/forms/2fa/recovery-codes.tsx @@ -0,0 +1,43 @@ +'use client'; + +import { useState } from 'react'; + +import { Button } from '@documenso/ui/primitives/button'; + +import { ViewRecoveryCodesDialog } from './view-recovery-codes-dialog'; + +type RecoveryCodesProps = { + // backupCodes: string[] | null; + isTwoFactorEnabled: boolean; +}; + +export const RecoveryCodes = ({ isTwoFactorEnabled }: RecoveryCodesProps) => { + const [isOpen, setIsOpen] = useState(false); + + return ( + <> +
+
+

Recovery Codes

+ +

+ 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/view-recovery-codes-dialog.tsx b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx new file mode 100644 index 000000000..6275f16d6 --- /dev/null +++ b/apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx @@ -0,0 +1,151 @@ +import { useMemo } from 'react'; + +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { match } from 'ts-pattern'; +import { z } from 'zod'; + +import { trpc } from '@documenso/trpc/react'; +import { Button } from '@documenso/ui/primitives/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} 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 { useToast } from '@documenso/ui/primitives/use-toast'; + +import { RecoveryCodeList } from './recovery-code-list'; + +export const ZViewRecoveryCodesForm = z.object({ + password: z.string().min(6).max(72), +}); + +export type TViewRecoveryCodesForm = z.infer; + +export type ViewRecoveryCodesDialogProps = { + open: boolean; + onOpenChange: (_open: boolean) => void; +}; + +export const ViewRecoveryCodesDialog = ({ open, onOpenChange }: ViewRecoveryCodesDialogProps) => { + const { toast } = useToast(); + + const { mutateAsync: viewRecoveryCodes, data: viewRecoveryCodesData } = + trpc.twoFactorAuthentication.viewRecoveryCodes.useMutation(); + + const viewRecoveryCodesForm = useForm({ + defaultValues: { + password: '', + }, + resolver: zodResolver(ZViewRecoveryCodesForm), + }); + + const { isSubmitting: isViewRecoveryCodesSubmitting } = viewRecoveryCodesForm.formState; + + const step = useMemo(() => { + if (!viewRecoveryCodesData || isViewRecoveryCodesSubmitting) { + return 'authenticate'; + } + + return 'view'; + }, [viewRecoveryCodesData, isViewRecoveryCodesSubmitting]); + + 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', + }); + } + }; + + return ( + + + + View Recovery Codes + + {step === 'authenticate' && ( + + To view your recovery codes, please enter your password below. + + )} + + {step === 'view' && ( + + Your recovery codes are listed below. Please store them in a safe place. + + )} + + + {match(step) + .with('authenticate', () => { + return ( +
+ + ( + + Password + + + + + + )} + /> + +
+ + + +
+ + + ); + }) + .with('view', () => ( +
+ {viewRecoveryCodesData?.recoveryCodes && ( + + )} + +
+ +
+
+ )) + .exhaustive()} +
+
+ ); +}; diff --git a/apps/web/src/components/forms/signin.tsx b/apps/web/src/components/forms/signin.tsx index abdc1efe6..0d7dd723f 100644 --- a/apps/web/src/components/forms/signin.tsx +++ b/apps/web/src/components/forms/signin.tsx @@ -3,7 +3,6 @@ import { useState } from 'react'; import { zodResolver } from '@hookform/resolvers/zod'; -import { Eye, EyeOff } from 'lucide-react'; import { signIn } from 'next-auth/react'; import { useForm } from 'react-hook-form'; import { FcGoogle } from 'react-icons/fc'; @@ -12,23 +11,30 @@ import { z } from 'zod'; import { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes'; import { cn } from '@documenso/ui/lib/utils'; import { Button } from '@documenso/ui/primitives/button'; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@documenso/ui/primitives/dialog'; import { FormErrorMessage } from '@documenso/ui/primitives/form/form-error-message'; -import { Input } from '@documenso/ui/primitives/input'; +import { Input, PasswordInput } from '@documenso/ui/primitives/input'; import { Label } from '@documenso/ui/primitives/label'; import { useToast } from '@documenso/ui/primitives/use-toast'; -const ERROR_MESSAGES = { +const ERROR_MESSAGES: Partial> = { [ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect', [ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect', [ErrorCode.USER_MISSING_PASSWORD]: 'This account appears to be using a social login method, please sign in using that method', + [ErrorCode.INCORRECT_TWO_FACTOR_CODE]: 'The two-factor authentication code provided is incorrect', + [ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE]: 'The backup code provided is incorrect', }; +const TwoFactorEnabledErrorCode = ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS; + const LOGIN_REDIRECT_PATH = '/documents'; export const ZSignInFormSchema = z.object({ email: z.string().email().min(1), - password: z.string().min(6, { message: 'Invalid password' }).max(72), + password: z.string().min(6).max(72), + totpCode: z.string().trim().optional(), + backupCode: z.string().trim().optional(), }); export type TSignInFormSchema = z.infer; @@ -39,33 +45,84 @@ export type SignInFormProps = { export const SignInForm = ({ className }: SignInFormProps) => { const { toast } = useToast(); - const [showPassword, setShowPassword] = useState(false); + const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] = + useState(false); + + const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState< + 'totp' | 'backup' + >('totp'); const { register, handleSubmit, + setValue, formState: { errors, isSubmitting }, } = useForm({ values: { email: '', password: '', + totpCode: '', + backupCode: '', }, resolver: zodResolver(ZSignInFormSchema), }); - const onFormSubmit = async ({ email, password }: TSignInFormSchema) => { + const onCloseTwoFactorAuthenticationDialog = () => { + setValue('totpCode', ''); + setValue('backupCode', ''); + + setIsTwoFactorAuthenticationDialogOpen(false); + }; + + const onToggleTwoFactorAuthenticationMethodClick = () => { + const method = twoFactorAuthenticationMethod === 'totp' ? 'backup' : 'totp'; + + if (method === 'totp') { + setValue('backupCode', ''); + } + + if (method === 'backup') { + setValue('totpCode', ''); + } + + setTwoFactorAuthenticationMethod(method); + }; + + const onFormSubmit = async ({ email, password, totpCode, backupCode }: TSignInFormSchema) => { try { - const result = await signIn('credentials', { + const credentials: Record = { email, password, + }; + + if (totpCode) { + credentials.totpCode = totpCode; + } + + if (backupCode) { + credentials.backupCode = backupCode; + } + + const result = await signIn('credentials', { + ...credentials, + callbackUrl: LOGIN_REDIRECT_PATH, redirect: false, }); if (result?.error && isErrorCode(result.error)) { + if (result.error === TwoFactorEnabledErrorCode) { + setIsTwoFactorAuthenticationDialogOpen(true); + + return; + } + + const errorMessage = ERROR_MESSAGES[result.error]; + toast({ variant: 'destructive', - description: ERROR_MESSAGES[result.error], + title: 'Unable to sign in', + description: errorMessage ?? 'An unknown error occurred', }); return; @@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => { Password -
- - - -
+
@@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => { Google + + + + + Two-Factor Authentication + + +
+ {twoFactorAuthenticationMethod === 'totp' && ( +
+ + + + + +
+ )} + + {twoFactorAuthenticationMethod === 'backup' && ( +
+ + + + + +
+ )} + +
+ + + +
+
+
+
); }; diff --git a/package-lock.json b/package-lock.json index 6f5a6d6a9..56d332429 100644 --- a/package-lock.json +++ b/package-lock.json @@ -111,6 +111,7 @@ "sharp": "0.32.5", "ts-pattern": "^5.0.5", "typescript": "5.2.2", + "uqr": "^0.1.2", "zod": "^3.22.4" }, "devDependencies": { @@ -2860,6 +2861,465 @@ "node": ">= 10" } }, + "node_modules/@noble/ciphers": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-0.4.0.tgz", + "integrity": "sha512-xaUaUUDWbHIFSxaQ/pIe+33VG2mfJp6N/KxKLmZr5biWdNznCAmfu24QRhX10BbVAuqOahAoyp0S4M9md6GPDw==", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-1.3.2.tgz", + "integrity": "sha512-MVC8EAQp7MvEcm30KWENFjgR+Mkmf+D189XJTkFIlwohU5hcBbn1ZkKq7KVTi2Hme3PMGF390DaL52beVrIihQ==", + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@node-rs/argon2": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2/-/argon2-1.5.2.tgz", + "integrity": "sha512-qq7wOSsdP2b4rXEapWNmsCjpaTGZWtp9kZmri98GYCDZqN8UJUG5zSue4XtYWWJMWKJVE/hkaIwk+BgN1ZUn0Q==", + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@node-rs/argon2-android-arm-eabi": "1.5.2", + "@node-rs/argon2-android-arm64": "1.5.2", + "@node-rs/argon2-darwin-arm64": "1.5.2", + "@node-rs/argon2-darwin-x64": "1.5.2", + "@node-rs/argon2-freebsd-x64": "1.5.2", + "@node-rs/argon2-linux-arm-gnueabihf": "1.5.2", + "@node-rs/argon2-linux-arm64-gnu": "1.5.2", + "@node-rs/argon2-linux-arm64-musl": "1.5.2", + "@node-rs/argon2-linux-x64-gnu": "1.5.2", + "@node-rs/argon2-linux-x64-musl": "1.5.2", + "@node-rs/argon2-win32-arm64-msvc": "1.5.2", + "@node-rs/argon2-win32-ia32-msvc": "1.5.2", + "@node-rs/argon2-win32-x64-msvc": "1.5.2" + } + }, + "node_modules/@node-rs/argon2-android-arm-eabi": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm-eabi/-/argon2-android-arm-eabi-1.5.2.tgz", + "integrity": "sha512-vVZec4ITr9GumAy0p8Zj8ozie362gtbZrTkLp9EqvuFZ/HrZzR09uS2IsDgm4mAstg/rc4A1gLRrHI8jDdbjkA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-android-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-android-arm64/-/argon2-android-arm64-1.5.2.tgz", + "integrity": "sha512-SwhnsXyrpgtWDTwYds1WUnxLA/kVP8HVaImYwQ3Wemqj1lkzcSoIaNyjNWkyrYGqO1tVc1YUrqsbd5eCHh+3sg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-arm64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-arm64/-/argon2-darwin-arm64-1.5.2.tgz", + "integrity": "sha512-+1ZMKiCCv2pip/o1Xg09piQru2LOIBPQ1vS4is86f55N3jjZnSfP+db5mYCSRuB0gRYqui98he7su7OGXlF4gQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-darwin-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-darwin-x64/-/argon2-darwin-x64-1.5.2.tgz", + "integrity": "sha512-mQ57mORlsxpfjcEsVpiHyHCOp6Ljrz/rVNWk8ihnPWw0qt0EqF1zbHRxTEPemL1iBHL9UyXpXrKS4JKq6xMn5w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-freebsd-x64": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-freebsd-x64/-/argon2-freebsd-x64-1.5.2.tgz", + "integrity": "sha512-UjKbFd3viYcpiwflkU4haEdNUMk1V2fVCJImWLWQns/hVval9BrDv5xsBwgdynbPHDlPOiWj816LBQwhWLGVWA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm-gnueabihf": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm-gnueabihf/-/argon2-linux-arm-gnueabihf-1.5.2.tgz", + "integrity": "sha512-36GJjJBnVuscV9CTn8RVDeJysnmIzr6Lp7QBCDczYHi6eKFuA8udCJb4SRyJqdvIuzycKG1RL56FbcFBJYCYIA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-gnu": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-gnu/-/argon2-linux-arm64-gnu-1.5.2.tgz", + "integrity": "sha512-sE0ydb2gp6xC+5vbVz8l3paaiBbFQIB2Rwp5wx9MmKiYdTfcO5WkGeADuSgoFiTcSEz1RsHXqrdVy6j/LtSqtA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-arm64-musl": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-arm64-musl/-/argon2-linux-arm64-musl-1.5.2.tgz", + "integrity": "sha512-LhE0YHB0aJCwlbsQrwePik/KFWUc9qMriJIL5KiejK3bDoTVY4ihH587QT56JyaLvl3nBJaAV8l5yMqQdHnouA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-gnu": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-gnu/-/argon2-linux-x64-gnu-1.5.2.tgz", + "integrity": "sha512-MnKLiBlyg05pxvKXe3lNgBL9El9ThD74hvVEiWH1Xk40RRrJ507NCOWXVmQ0FDq1mjTeGFxbIvk+AcoF0NSLIQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-linux-x64-musl": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-linux-x64-musl/-/argon2-linux-x64-musl-1.5.2.tgz", + "integrity": "sha512-tzLgASY0Ng2OTW7Awwl9UWzjbWx8/uD6gXcZ/k/nYGSZE5Xp8EOD2NUqHLbK6KZE3775A0R25ShpiSxCadYqkg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-arm64-msvc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-arm64-msvc/-/argon2-win32-arm64-msvc-1.5.2.tgz", + "integrity": "sha512-vpTwSvv3oUXTpWZh0/HxdJ5wFMlmS7aVDwL4ATWepTZhMG4n+TO0+tVLdcPHCbg0oc6hCWBjWNPlSn9mW+YIgA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-ia32-msvc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-ia32-msvc/-/argon2-win32-ia32-msvc-1.5.2.tgz", + "integrity": "sha512-KPpZR15ui7uQWQXKmtaKyUQRs4UJdXnIIfiyFLGmLWCdEKlr3MtIGFt0fdziu4BF5ZObD8Ic6QvT0VXK4OJiww==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/argon2-win32-x64-msvc": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@node-rs/argon2-win32-x64-msvc/-/argon2-win32-x64-msvc-1.5.2.tgz", + "integrity": "sha512-/pGuwixJS8ZlpwhX9iM6g6JEeZYo1TtnNf8exwsOi7gxcUoTUfw5it+5GfbY/n+xRBz/DIU4bzUmXmh+7Gh0ug==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt/-/bcrypt-1.7.3.tgz", + "integrity": "sha512-BF6u9CBPUiyk1zU+5iwikezf+xM4MFSu5cmrrg/PLKffGgIM13ZsY6DHftcTraETB04ryasjM/5IejotH+sO5Q==", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@node-rs/bcrypt-android-arm-eabi": "1.7.3", + "@node-rs/bcrypt-android-arm64": "1.7.3", + "@node-rs/bcrypt-darwin-arm64": "1.7.3", + "@node-rs/bcrypt-darwin-x64": "1.7.3", + "@node-rs/bcrypt-freebsd-x64": "1.7.3", + "@node-rs/bcrypt-linux-arm-gnueabihf": "1.7.3", + "@node-rs/bcrypt-linux-arm64-gnu": "1.7.3", + "@node-rs/bcrypt-linux-arm64-musl": "1.7.3", + "@node-rs/bcrypt-linux-x64-gnu": "1.7.3", + "@node-rs/bcrypt-linux-x64-musl": "1.7.3", + "@node-rs/bcrypt-win32-arm64-msvc": "1.7.3", + "@node-rs/bcrypt-win32-ia32-msvc": "1.7.3", + "@node-rs/bcrypt-win32-x64-msvc": "1.7.3" + } + }, + "node_modules/@node-rs/bcrypt-android-arm-eabi": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm-eabi/-/bcrypt-android-arm-eabi-1.7.3.tgz", + "integrity": "sha512-l53RuBqnqNvBN2jx09Ws6jpLmuQdSDx10n0GeaTfwh1svxsC8bPpVmxkfBExsT2Tu7KF38gTnPZvwsxysZQyPQ==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-android-arm64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-android-arm64/-/bcrypt-android-arm64-1.7.3.tgz", + "integrity": "sha512-TZpm4VbiViqDMvusrcYzLr1b1M5FDF0cDNiTUciLeBSsKtU5lNdEZGAU7gvCnrKoUWpGuOblHU7613zuB7SiNQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-arm64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-arm64/-/bcrypt-darwin-arm64-1.7.3.tgz", + "integrity": "sha512-SiUuAabynVsmixZMjh5xrn8w47EnV0HzbW9st4DPoVhn/wzdUcksIXDY75aoQG2EIzKLN8IGb+CIVnPGmRyhxw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-darwin-x64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-darwin-x64/-/bcrypt-darwin-x64-1.7.3.tgz", + "integrity": "sha512-R+81Z0eX4hZPvCXY5Z6l0l+JrTU3WcSYGHP0QYV9uwdaafOz6EhrCXUzZ02AIcAbNoVR8eucYVruq9PiasXoVw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-freebsd-x64": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-freebsd-x64/-/bcrypt-freebsd-x64-1.7.3.tgz", + "integrity": "sha512-0pItU/5K3e83JjcJj9fZv+78txUoZ3hHCT7n/UMdu9mkpUzhX/rqb4jmQpJpD+UQoR76xp3qDo5RMgQBffBVNg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm-gnueabihf": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm-gnueabihf/-/bcrypt-linux-arm-gnueabihf-1.7.3.tgz", + "integrity": "sha512-HTSybWUjNe8rWuXkTkMeFDiQNHc6VioRcgv6AeHZphIxiT6dFbnhXNkfz4Hr0zxvyPhZ3NrYjT2AmPVFT6VW3Q==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-gnu": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-gnu/-/bcrypt-linux-arm64-gnu-1.7.3.tgz", + "integrity": "sha512-rWep6Y+v/c4bZHaM8LmSsrMwMmDR9wG4/q+3Z9VzR8xdnt5VCbuQdYWpf3sgGRGjTRdTBAdSK8x1reOjqsJ3Jg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-arm64-musl": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-arm64-musl/-/bcrypt-linux-arm64-musl-1.7.3.tgz", + "integrity": "sha512-TyWEKhxr+yfGcMKzVV/ARZw+Hrky2yl91bo0XYU2ZW6I6LDC0emNsXugdWjwz8ADI4OWhhrOjXD8GCilxiB2Rg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-gnu": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-gnu/-/bcrypt-linux-x64-gnu-1.7.3.tgz", + "integrity": "sha512-PofxM1Qg7tZKj1oP0I7tBTSSLr8Xc2uxx+P3pBCPmYzaBwWqGteNHJlF7n2q5xiH7YOlguH4w5CmcEjsiA3K4A==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-linux-x64-musl": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-linux-x64-musl/-/bcrypt-linux-x64-musl-1.7.3.tgz", + "integrity": "sha512-D5V6/dDVKP8S/ieDBLGhTn4oTo3upbrpWInynbhOMjJvPiIxVG1PiI3MXkWBtG9qtfleDk7gUkEKtAOxlIxDTQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-arm64-msvc": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-arm64-msvc/-/bcrypt-win32-arm64-msvc-1.7.3.tgz", + "integrity": "sha512-b4gH2Yj5R4TwULrfMHd1Qqr+MrnFjVRUAJujDKPqi+PppSqezW8QF6DRSOL4GjnBmz5JEd64wxgeidvy7dsbGw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-ia32-msvc": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-ia32-msvc/-/bcrypt-win32-ia32-msvc-1.7.3.tgz", + "integrity": "sha512-E91ro+ybI0RhNc89aGaZQGll0YhPoHr8JacoWrNKwhg9zwNOYeuO0tokdMZdm6nF0/8obll0Mq7wO9AXO9iffw==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@node-rs/bcrypt-win32-x64-msvc": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@node-rs/bcrypt-win32-x64-msvc/-/bcrypt-win32-x64-msvc-1.7.3.tgz", + "integrity": "sha512-LO/p9yjPODj/pQvPnowBuwpDdqiyUXQbqL1xb1RSP3NoyCFAGmjL5h0plSQrhLh8hskQiozBRXNaQurtsM7o0Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -14300,6 +14760,15 @@ "node": ">=8" } }, + "node_modules/oslo": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/oslo/-/oslo-0.17.0.tgz", + "integrity": "sha512-UJHew6zFEkJYGWjO4/ARHnX+M7umhJ6IXc6cJA2AQ3BpFwqEqaKjySOfXYuNFQddzfP2zk1aG+xYQG1ODHKwfQ==", + "dependencies": { + "@node-rs/argon2": "^1.5.2", + "@node-rs/bcrypt": "^1.7.3" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -18457,6 +18926,11 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uqr": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/uqr/-/uqr-0.1.2.tgz", + "integrity": "sha512-MJu7ypHq6QasgF5YRTjqscSzQp/W11zoUk6kvmlH+fmWEs63Y0Eib13hYFwAzagRJcVY8WVnlV+eBDUGMJ5IbA==" + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -19781,6 +20255,8 @@ "@documenso/prisma": "*", "@documenso/signing": "*", "@next-auth/prisma-adapter": "1.0.7", + "@noble/ciphers": "0.4.0", + "@noble/hashes": "1.3.2", "@pdf-lib/fontkit": "^1.1.1", "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", @@ -19790,6 +20266,7 @@ "nanoid": "^4.0.2", "next": "14.0.0", "next-auth": "4.24.3", + "oslo": "^0.17.0", "pdf-lib": "^1.17.1", "react": "18.2.0", "remeda": "^1.27.1", @@ -19891,7 +20368,8 @@ "superjson": "^1.13.1", "ts-pattern": "^5.0.5", "zod": "^3.22.4" - } + }, + "devDependencies": {} }, "packages/tsconfig": { "name": "@documenso/tsconfig", diff --git a/packages/lib/constants/crypto.ts b/packages/lib/constants/crypto.ts new file mode 100644 index 000000000..d911cd6cf --- /dev/null +++ b/packages/lib/constants/crypto.ts @@ -0,0 +1 @@ +export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY; diff --git a/packages/lib/next-auth/auth-options.ts b/packages/lib/next-auth/auth-options.ts index 57a37d7fe..6d59b0666 100644 --- a/packages/lib/next-auth/auth-options.ts +++ b/packages/lib/next-auth/auth-options.ts @@ -10,6 +10,8 @@ import GoogleProvider from 'next-auth/providers/google'; import { prisma } from '@documenso/prisma'; +import { isTwoFactorAuthenticationEnabled } from '../server-only/2fa/is-2fa-availble'; +import { validateTwoFactorAuthentication } from '../server-only/2fa/validate-2fa'; import { getUserByEmail } from '../server-only/user/get-user-by-email'; import { ErrorCode } from './error-codes'; @@ -25,13 +27,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { credentials: { email: { label: 'Email', type: 'email' }, password: { label: 'Password', type: 'password' }, + totpCode: { + label: 'Two-factor Code', + type: 'input', + placeholder: 'Code from authenticator app', + }, + backupCode: { label: 'Backup Code', type: 'input', placeholder: 'Two-factor backup code' }, }, authorize: async (credentials, _req) => { if (!credentials) { throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND); } - const { email, password } = credentials; + const { email, password, backupCode, totpCode } = credentials; const user = await getUserByEmail({ email }).catch(() => { throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD); @@ -47,6 +55,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = { throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD); } + const is2faEnabled = isTwoFactorAuthenticationEnabled({ user }); + + if (is2faEnabled) { + const isValid = await validateTwoFactorAuthentication({ backupCode, totpCode, user }); + + if (!isValid) { + throw new Error( + totpCode + ? ErrorCode.INCORRECT_TWO_FACTOR_CODE + : ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE, + ); + } + } + return { id: Number(user.id), email: user.email, diff --git a/packages/lib/next-auth/error-codes.ts b/packages/lib/next-auth/error-codes.ts index 26e8f5b97..c3dfafece 100644 --- a/packages/lib/next-auth/error-codes.ts +++ b/packages/lib/next-auth/error-codes.ts @@ -8,4 +8,15 @@ export const ErrorCode = { INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD', USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD', CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND', + INTERNAL_SEVER_ERROR: 'INTERNAL_SEVER_ERROR', + TWO_FACTOR_ALREADY_ENABLED: 'TWO_FACTOR_ALREADY_ENABLED', + TWO_FACTOR_SETUP_REQUIRED: 'TWO_FACTOR_SETUP_REQUIRED', + TWO_FACTOR_MISSING_SECRET: 'TWO_FACTOR_MISSING_SECRET', + TWO_FACTOR_MISSING_CREDENTIALS: 'TWO_FACTOR_MISSING_CREDENTIALS', + INCORRECT_TWO_FACTOR_CODE: 'INCORRECT_TWO_FACTOR_CODE', + INCORRECT_TWO_FACTOR_BACKUP_CODE: 'INCORRECT_TWO_FACTOR_BACKUP_CODE', + INCORRECT_IDENTITY_PROVIDER: 'INCORRECT_IDENTITY_PROVIDER', + INCORRECT_PASSWORD: 'INCORRECT_PASSWORD', + MISSING_ENCRYPTION_KEY: 'MISSING_ENCRYPTION_KEY', + MISSING_BACKUP_CODE: 'MISSING_BACKUP_CODE', } as const; diff --git a/packages/lib/package.json b/packages/lib/package.json index 56be5a7f0..e9d321e5b 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -25,6 +25,8 @@ "@documenso/prisma": "*", "@documenso/signing": "*", "@next-auth/prisma-adapter": "1.0.7", + "@noble/ciphers": "0.4.0", + "@noble/hashes": "1.3.2", "@pdf-lib/fontkit": "^1.1.1", "@scure/base": "^1.1.3", "@sindresorhus/slugify": "^2.2.1", @@ -34,6 +36,7 @@ "nanoid": "^4.0.2", "next": "14.0.0", "next-auth": "4.24.3", + "oslo": "^0.17.0", "pdf-lib": "^1.17.1", "react": "18.2.0", "remeda": "^1.27.1", diff --git a/packages/lib/server-only/2fa/disable-2fa.ts b/packages/lib/server-only/2fa/disable-2fa.ts new file mode 100644 index 000000000..5b27d5c9d --- /dev/null +++ b/packages/lib/server-only/2fa/disable-2fa.ts @@ -0,0 +1,48 @@ +import { compare } from 'bcrypt'; + +import { prisma } from '@documenso/prisma'; +import { User } from '@documenso/prisma/client'; + +import { ErrorCode } from '../../next-auth/error-codes'; +import { validateTwoFactorAuthentication } from './validate-2fa'; + +type DisableTwoFactorAuthenticationOptions = { + user: User; + backupCode: string; + password: string; +}; + +export const disableTwoFactorAuthentication = async ({ + backupCode, + user, + password, +}: 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 }); + + if (!isValid) { + throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_BACKUP_CODE); + } + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: false, + twoFactorBackupCodes: null, + twoFactorSecret: null, + }, + }); + + return true; +}; diff --git a/packages/lib/server-only/2fa/enable-2fa.ts b/packages/lib/server-only/2fa/enable-2fa.ts new file mode 100644 index 000000000..9f61e52a4 --- /dev/null +++ b/packages/lib/server-only/2fa/enable-2fa.ts @@ -0,0 +1,47 @@ +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { prisma } from '@documenso/prisma'; +import { User } from '@documenso/prisma/client'; + +import { getBackupCodes } from './get-backup-code'; +import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; + +type EnableTwoFactorAuthenticationOptions = { + user: User; + code: string; +}; + +export const enableTwoFactorAuthentication = async ({ + user, + code, +}: EnableTwoFactorAuthenticationOptions) => { + if (user.identityProvider !== 'DOCUMENSO') { + throw new Error(ErrorCode.INCORRECT_IDENTITY_PROVIDER); + } + + if (user.twoFactorEnabled) { + throw new Error(ErrorCode.TWO_FACTOR_ALREADY_ENABLED); + } + + if (!user.twoFactorSecret) { + throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED); + } + + const isValidToken = await verifyTwoFactorAuthenticationToken({ user, totpCode: code }); + + if (!isValidToken) { + throw new Error(ErrorCode.INCORRECT_TWO_FACTOR_CODE); + } + + const updatedUser = await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: true, + }, + }); + + const recoveryCodes = getBackupCodes({ user: updatedUser }); + + return { recoveryCodes }; +}; diff --git a/packages/lib/server-only/2fa/get-backup-code.ts b/packages/lib/server-only/2fa/get-backup-code.ts new file mode 100644 index 000000000..e1188f37a --- /dev/null +++ b/packages/lib/server-only/2fa/get-backup-code.ts @@ -0,0 +1,38 @@ +import { z } from 'zod'; + +import { User } from '@documenso/prisma/client'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; +import { symmetricDecrypt } from '../../universal/crypto'; + +interface GetBackupCodesOptions { + user: User; +} + +const ZBackupCodeSchema = z.array(z.string()); + +export const getBackupCodes = ({ user }: GetBackupCodesOptions) => { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!user.twoFactorEnabled) { + throw new Error('User has not enabled 2FA'); + } + + if (!user.twoFactorBackupCodes) { + throw new Error('User has no backup codes'); + } + + const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorBackupCodes })).toString( + 'utf-8', + ); + + const data = JSON.parse(secret); + + const result = ZBackupCodeSchema.safeParse(data); + + if (result.success) { + return result.data; + } + + return null; +}; diff --git a/packages/lib/server-only/2fa/is-2fa-availble.ts b/packages/lib/server-only/2fa/is-2fa-availble.ts new file mode 100644 index 000000000..d06a0085d --- /dev/null +++ b/packages/lib/server-only/2fa/is-2fa-availble.ts @@ -0,0 +1,17 @@ +import { User } from '@documenso/prisma/client'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; + +type IsTwoFactorAuthenticationEnabledOptions = { + user: User; +}; + +export const isTwoFactorAuthenticationEnabled = ({ + user, +}: IsTwoFactorAuthenticationEnabledOptions) => { + return ( + user.twoFactorEnabled && + user.identityProvider === 'DOCUMENSO' && + typeof DOCUMENSO_ENCRYPTION_KEY === 'string' + ); +}; diff --git a/packages/lib/server-only/2fa/setup-2fa.ts b/packages/lib/server-only/2fa/setup-2fa.ts new file mode 100644 index 000000000..30ddf0ec3 --- /dev/null +++ b/packages/lib/server-only/2fa/setup-2fa.ts @@ -0,0 +1,76 @@ +import { base32 } from '@scure/base'; +import { compare } from 'bcrypt'; +import crypto from 'crypto'; +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 { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; +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; + + if (!key) { + 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 = new Array(10) + .fill(null) + .map(() => crypto.randomBytes(5).toString('hex')) + .map((code) => `${code.slice(0, 5)}-${code.slice(5)}`.toUpperCase()); + + const accountName = user.email; + const uri = createTOTPKeyURI(ISSUER, accountName, secret); + const encodedSecret = base32.encode(secret); + + await prisma.user.update({ + where: { + id: user.id, + }, + data: { + twoFactorEnabled: false, + twoFactorBackupCodes: symmetricEncrypt({ + data: JSON.stringify(backupCodes), + key: key, + }), + twoFactorSecret: symmetricEncrypt({ + data: encodedSecret, + key: key, + }), + }, + }); + + return { + secret: encodedSecret, + uri, + }; +}; diff --git a/packages/lib/server-only/2fa/validate-2fa.ts b/packages/lib/server-only/2fa/validate-2fa.ts new file mode 100644 index 000000000..7fc76a8bb --- /dev/null +++ b/packages/lib/server-only/2fa/validate-2fa.ts @@ -0,0 +1,35 @@ +import { User } from '@documenso/prisma/client'; + +import { ErrorCode } from '../../next-auth/error-codes'; +import { verifyTwoFactorAuthenticationToken } from './verify-2fa-token'; +import { verifyBackupCode } from './verify-backup-code'; + +type ValidateTwoFactorAuthenticationOptions = { + totpCode?: string; + backupCode?: string; + user: User; +}; + +export const validateTwoFactorAuthentication = async ({ + backupCode, + totpCode, + user, +}: ValidateTwoFactorAuthenticationOptions) => { + if (!user.twoFactorEnabled) { + throw new Error(ErrorCode.TWO_FACTOR_SETUP_REQUIRED); + } + + if (!user.twoFactorSecret) { + throw new Error(ErrorCode.TWO_FACTOR_MISSING_SECRET); + } + + if (totpCode) { + return await verifyTwoFactorAuthenticationToken({ user, totpCode }); + } + + if (backupCode) { + return await verifyBackupCode({ user, backupCode }); + } + + throw new Error(ErrorCode.TWO_FACTOR_MISSING_CREDENTIALS); +}; diff --git a/packages/lib/server-only/2fa/verify-2fa-token.ts b/packages/lib/server-only/2fa/verify-2fa-token.ts new file mode 100644 index 000000000..fa9159517 --- /dev/null +++ b/packages/lib/server-only/2fa/verify-2fa-token.ts @@ -0,0 +1,33 @@ +import { base32 } from '@scure/base'; +import { TOTPController } from 'oslo/otp'; + +import { User } from '@documenso/prisma/client'; + +import { DOCUMENSO_ENCRYPTION_KEY } from '../../constants/crypto'; +import { symmetricDecrypt } from '../../universal/crypto'; + +const totp = new TOTPController(); + +type VerifyTwoFactorAuthenticationTokenOptions = { + user: User; + totpCode: string; +}; + +export const verifyTwoFactorAuthenticationToken = async ({ + user, + totpCode, +}: VerifyTwoFactorAuthenticationTokenOptions) => { + const key = DOCUMENSO_ENCRYPTION_KEY; + + if (!user.twoFactorSecret) { + throw new Error('user missing 2fa secret'); + } + + const secret = Buffer.from(symmetricDecrypt({ key, data: user.twoFactorSecret })).toString( + 'utf-8', + ); + + const isValidToken = await totp.verify(totpCode, base32.decode(secret)); + + return isValidToken; +}; diff --git a/packages/lib/server-only/2fa/verify-backup-code.ts b/packages/lib/server-only/2fa/verify-backup-code.ts new file mode 100644 index 000000000..357d4994c --- /dev/null +++ b/packages/lib/server-only/2fa/verify-backup-code.ts @@ -0,0 +1,18 @@ +import { User } from '@documenso/prisma/client'; + +import { getBackupCodes } from './get-backup-code'; + +type VerifyBackupCodeParams = { + user: User; + backupCode: string; +}; + +export const verifyBackupCode = async ({ user, backupCode }: VerifyBackupCodeParams) => { + const userBackupCodes = await getBackupCodes({ user }); + + if (!userBackupCodes) { + throw new Error('User has no backup codes'); + } + + return userBackupCodes.includes(backupCode); +}; diff --git a/packages/lib/server-only/auth/hash.ts b/packages/lib/server-only/auth/hash.ts index 1de2ac458..df9931c97 100644 --- a/packages/lib/server-only/auth/hash.ts +++ b/packages/lib/server-only/auth/hash.ts @@ -1,4 +1,4 @@ -import { hashSync as bcryptHashSync } from 'bcrypt'; +import { compareSync as bcryptCompareSync, hashSync as bcryptHashSync } from 'bcrypt'; import { SALT_ROUNDS } from '../../constants/auth'; @@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth'; export const hashSync = (password: string) => { return bcryptHashSync(password, SALT_ROUNDS); }; + +export const compareSync = (password: string, hash: string) => { + return bcryptCompareSync(password, hash); +}; diff --git a/packages/lib/universal/crypto.ts b/packages/lib/universal/crypto.ts new file mode 100644 index 000000000..405208d7f --- /dev/null +++ b/packages/lib/universal/crypto.ts @@ -0,0 +1,32 @@ +import { xchacha20poly1305 } from '@noble/ciphers/chacha'; +import { bytesToHex, hexToBytes, utf8ToBytes } from '@noble/ciphers/utils'; +import { managedNonce } from '@noble/ciphers/webcrypto/utils'; +import { sha256 } from '@noble/hashes/sha256'; + +export type SymmetricEncryptOptions = { + key: string; + data: string; +}; + +export const symmetricEncrypt = ({ key, data }: SymmetricEncryptOptions) => { + const keyAsBytes = sha256(key); + const dataAsBytes = utf8ToBytes(data); + + const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you + + return bytesToHex(chacha.encrypt(dataAsBytes)); +}; + +export type SymmetricDecryptOptions = { + key: string; + data: string; +}; + +export const symmetricDecrypt = ({ key, data }: SymmetricDecryptOptions) => { + const keyAsBytes = sha256(key); + const dataAsBytes = hexToBytes(data); + + const chacha = managedNonce(xchacha20poly1305)(keyAsBytes); // manages nonces for you + + return chacha.decrypt(dataAsBytes); +}; diff --git a/packages/prisma/migrations/20231105184518_add_2fa/migration.sql b/packages/prisma/migrations/20231105184518_add_2fa/migration.sql new file mode 100644 index 000000000..8456bdbc6 --- /dev/null +++ b/packages/prisma/migrations/20231105184518_add_2fa/migration.sql @@ -0,0 +1,4 @@ +-- AlterTable +ALTER TABLE "User" ADD COLUMN "twoFactorBackupCodes" TEXT, +ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false, +ADD COLUMN "twoFactorSecret" TEXT; diff --git a/packages/prisma/schema.prisma b/packages/prisma/schema.prisma index 02807e4a0..7407bc5c0 100644 --- a/packages/prisma/schema.prisma +++ b/packages/prisma/schema.prisma @@ -19,25 +19,28 @@ enum Role { } model User { - id Int @id @default(autoincrement()) - name String? - email String @unique - emailVerified DateTime? - password String? - source String? - signature String? - createdAt DateTime @default(now()) - updatedAt DateTime @default(now()) @updatedAt - lastSignedIn DateTime @default(now()) - roles Role[] @default([USER]) - identityProvider IdentityProvider @default(DOCUMENSO) - accounts Account[] - sessions Session[] - Document Document[] - Subscription Subscription? - PasswordResetToken PasswordResetToken[] - VerificationToken VerificationToken[] - + id Int @id @default(autoincrement()) + name String? + email String @unique + emailVerified DateTime? + password String? + source String? + signature String? + createdAt DateTime @default(now()) + updatedAt DateTime @default(now()) @updatedAt + lastSignedIn DateTime @default(now()) + roles Role[] @default([USER]) + identityProvider IdentityProvider @default(DOCUMENSO) + accounts Account[] + sessions Session[] + Document Document[] + Subscription Subscription? + PasswordResetToken PasswordResetToken[] + twoFactorSecret String? + twoFactorEnabled Boolean @default(false) + twoFactorBackupCodes String? + VerificationToken VerificationToken[] + @@index([email]) } diff --git a/packages/trpc/package.json b/packages/trpc/package.json index 05aed3147..54c1d5917 100644 --- a/packages/trpc/package.json +++ b/packages/trpc/package.json @@ -21,5 +21,6 @@ "superjson": "^1.13.1", "ts-pattern": "^5.0.5", "zod": "^3.22.4" - } + }, + "devDependencies": {} } diff --git a/packages/trpc/server/auth-router/router.ts b/packages/trpc/server/auth-router/router.ts index dfabd9da9..59c51ade5 100644 --- a/packages/trpc/server/auth-router/router.ts +++ b/packages/trpc/server/auth-router/router.ts @@ -1,10 +1,12 @@ import { TRPCError } from '@trpc/server'; +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +import { compareSync } from '@documenso/lib/server-only/auth/hash'; import { createUser } from '@documenso/lib/server-only/user/create-user'; import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token'; -import { procedure, router } from '../trpc'; -import { ZSignUpMutationSchema } from './schema'; +import { authenticatedProcedure, procedure, router } from '../trpc'; +import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema'; export const authRouter = router({ signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => { @@ -30,4 +32,23 @@ export const authRouter = router({ }); } }), + + verifyPassword: authenticatedProcedure + .input(ZVerifyPasswordMutationSchema) + .mutation(({ ctx, input }) => { + const user = ctx.user; + + const { password } = input; + + if (!user.password) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: ErrorCode.INCORRECT_PASSWORD, + }); + } + + const valid = compareSync(password, user.password); + + return valid; + }), }); diff --git a/packages/trpc/server/auth-router/schema.ts b/packages/trpc/server/auth-router/schema.ts index bdc9cd742..cc969c679 100644 --- a/packages/trpc/server/auth-router/schema.ts +++ b/packages/trpc/server/auth-router/schema.ts @@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({ }); export type TSignUpMutationSchema = z.infer; + +export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true }); diff --git a/packages/trpc/server/router.ts b/packages/trpc/server/router.ts index dbf6ca03d..5b09478dc 100644 --- a/packages/trpc/server/router.ts +++ b/packages/trpc/server/router.ts @@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router'; import { shareLinkRouter } from './share-link-router/router'; import { singleplayerRouter } from './singleplayer-router/router'; import { router } from './trpc'; +import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router'; export const appRouter = router({ auth: authRouter, @@ -15,6 +16,7 @@ export const appRouter = router({ admin: adminRouter, shareLink: shareLinkRouter, singleplayer: singleplayerRouter, + twoFactorAuthentication: twoFactorAuthenticationRouter, }); export type AppRouter = typeof appRouter; diff --git a/packages/trpc/server/two-factor-authentication-router/router.ts b/packages/trpc/server/two-factor-authentication-router/router.ts new file mode 100644 index 000000000..a10f7a543 --- /dev/null +++ b/packages/trpc/server/two-factor-authentication-router/router.ts @@ -0,0 +1,105 @@ +import { TRPCError } from '@trpc/server'; + +import { ErrorCode } from '@documenso/lib/next-auth/error-codes'; +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 { 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; + + return await setupTwoFactorAuthentication({ user, password }); + }), + + enable: authenticatedProcedure + .input(ZEnableTwoFactorAuthenticationMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const user = ctx.user; + + const { code } = input; + + return await enableTwoFactorAuthentication({ user, code }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to enable two-factor authentication. Please try again later.', + }); + } + }), + + disable: authenticatedProcedure + .input(ZDisableTwoFactorAuthenticationMutationSchema) + .mutation(async ({ ctx, input }) => { + try { + const user = ctx.user; + + const { password, backupCode } = input; + + return await disableTwoFactorAuthentication({ user, password, backupCode }); + } catch (err) { + console.error(err); + + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'We were unable to disable two-factor authentication. Please try again later.', + }); + } + }), + + viewRecoveryCodes: authenticatedProcedure + .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.', + }); + } + }), +}); diff --git a/packages/trpc/server/two-factor-authentication-router/schema.ts b/packages/trpc/server/two-factor-authentication-router/schema.ts new file mode 100644 index 000000000..3a831845f --- /dev/null +++ b/packages/trpc/server/two-factor-authentication-router/schema.ts @@ -0,0 +1,32 @@ +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), +}); + +export type TEnableTwoFactorAuthenticationMutationSchema = z.infer< + typeof ZEnableTwoFactorAuthenticationMutationSchema +>; + +export const ZDisableTwoFactorAuthenticationMutationSchema = z.object({ + password: z.string().min(6).max(72), + backupCode: z.string().trim(), +}); + +export type TDisableTwoFactorAuthenticationMutationSchema = z.infer< + typeof ZDisableTwoFactorAuthenticationMutationSchema +>; + +export const ZViewRecoveryCodesMutationSchema = z.object({ + password: z.string().min(6).max(72), +}); + +export type TViewRecoveryCodesMutationSchema = z.infer; diff --git a/packages/tsconfig/process-env.d.ts b/packages/tsconfig/process-env.d.ts index 749cfcc43..717f13ade 100644 --- a/packages/tsconfig/process-env.d.ts +++ b/packages/tsconfig/process-env.d.ts @@ -7,6 +7,7 @@ declare namespace NodeJS { NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string; NEXT_PRIVATE_DATABASE_URL: string; + NEXT_PRIVATE_ENCRYPTION_KEY: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_MONTHLY_PRICE_ID: string; NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string; diff --git a/packages/ui/primitives/input.tsx b/packages/ui/primitives/input.tsx index 1a5fba1bb..ac739c984 100644 --- a/packages/ui/primitives/input.tsx +++ b/packages/ui/primitives/input.tsx @@ -1,6 +1,9 @@ import * as React from 'react'; +import { Eye, EyeOff } from 'lucide-react'; + import { cn } from '../lib/utils'; +import { Button } from './button'; export type InputProps = React.InputHTMLAttributes; @@ -25,4 +28,38 @@ const Input = React.forwardRef( Input.displayName = 'Input'; -export { Input }; +const PasswordInput = React.forwardRef( + ({ className, ...props }, ref) => { + const [showPassword, setShowPassword] = React.useState(false); + + return ( +
+ + + +
+ ); + }, +); + +PasswordInput.displayName = 'Input'; + +export { Input, PasswordInput }; diff --git a/turbo.json b/turbo.json index 0f0038887..36b169a80 100644 --- a/turbo.json +++ b/turbo.json @@ -33,6 +33,7 @@ "globalDependencies": ["**/.env.*local"], "globalEnv": [ "APP_VERSION", + "NEXT_PRIVATE_ENCRYPTION_KEY", "NEXTAUTH_URL", "NEXTAUTH_SECRET", "NEXT_PUBLIC_PROJECT",