mirror of
https://github.com/documenso/documenso.git
synced 2025-11-12 15:53:02 +10:00
feat: add two factor auth (#643)
Add two factor authentication for users who wish to enhance the security of their accounts.
This commit is contained in:
@ -2,6 +2,11 @@
|
|||||||
NEXTAUTH_URL="http://localhost:3000"
|
NEXTAUTH_URL="http://localhost:3000"
|
||||||
NEXTAUTH_SECRET="secret"
|
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]]
|
# [[AUTH OPTIONAL]]
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_ID=""
|
||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET=""
|
||||||
|
|||||||
@ -44,6 +44,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@ -41,7 +41,7 @@ export default async function BillingSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Billing</h3>
|
<h3 className="text-2xl font-semibold">Billing</h3>
|
||||||
|
|
||||||
<div className="text-muted-foreground mt-2 text-sm">
|
<div className="text-muted-foreground mt-2 text-sm">
|
||||||
{isMissingOrInactiveOrFreePlan && (
|
{isMissingOrInactiveOrFreePlan && (
|
||||||
|
|||||||
@ -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 function PasswordSettingsPage() {
|
||||||
|
redirect('/settings/security');
|
||||||
export default async function PasswordSettingsPage() {
|
|
||||||
const { user } = await getRequiredServerComponentSession();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<h3 className="text-lg font-medium">Password</h3>
|
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can update your password.</p>
|
|
||||||
|
|
||||||
<hr className="my-4" />
|
|
||||||
|
|
||||||
<PasswordForm user={user} className="max-w-xl" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ export default async function ProfileSettingsPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-lg font-medium">Profile</h3>
|
<h3 className="text-2xl font-semibold">Profile</h3>
|
||||||
|
|
||||||
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
<p className="text-muted-foreground mt-2 text-sm">Here you can edit your personal details.</p>
|
||||||
|
|
||||||
|
|||||||
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
46
apps/web/src/app/(dashboard)/settings/security/page.tsx
Normal file
@ -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 (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-2xl font-semibold">Security</h3>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Here you can manage your password and security settings.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<hr className="my-4" />
|
||||||
|
|
||||||
|
<PasswordForm user={user} className="max-w-xl" />
|
||||||
|
|
||||||
|
<hr className="mb-4 mt-8" />
|
||||||
|
|
||||||
|
<h4 className="text-lg font-medium">Two Factor Authentication</h4>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 text-sm">
|
||||||
|
Add and manage your two factor security settings to add an extra layer of security to your
|
||||||
|
account!
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Two-factor methods</h5>
|
||||||
|
|
||||||
|
<AuthenticatorApp isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.twoFactorEnabled && (
|
||||||
|
<div className="mt-4 max-w-xl">
|
||||||
|
<h5 className="font-medium">Recovery methods</h5>
|
||||||
|
|
||||||
|
<RecoveryCodes isTwoFactorEnabled={user.twoFactorEnabled} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ import Link from 'next/link';
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Key,
|
Lock,
|
||||||
LogOut,
|
LogOut,
|
||||||
User as LucideUser,
|
User as LucideUser,
|
||||||
Monitor,
|
Monitor,
|
||||||
@ -87,9 +87,9 @@ export const ProfileDropdown = ({ user }: ProfileDropdownProps) => {
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/settings/password" className="cursor-pointer">
|
<Link href="/settings/security" className="cursor-pointer">
|
||||||
<Key className="mr-2 h-4 w-4" />
|
<Lock className="mr-2 h-4 w-4" />
|
||||||
Password
|
Security
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -35,16 +35,16 @@ export const DesktopNav = ({ className, ...props }: DesktopNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/password">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key className="mr-2 h-5 w-5" />
|
<Lock className="mr-2 h-5 w-5" />
|
||||||
Password
|
Security
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import { HTMLAttributes } from 'react';
|
|||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
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 { useFeatureFlags } from '@documenso/lib/client-only/providers/feature-flag';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
@ -38,16 +38,16 @@ export const MobileNav = ({ className, ...props }: MobileNavProps) => {
|
|||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<Link href="/settings/password">
|
<Link href="/settings/security">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full justify-start',
|
'w-full justify-start',
|
||||||
pathname?.startsWith('/settings/password') && 'bg-secondary',
|
pathname?.startsWith('/settings/security') && 'bg-secondary',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Key className="mr-2 h-5 w-5" />
|
<Lock className="mr-2 h-5 w-5" />
|
||||||
Password
|
Security
|
||||||
</Button>
|
</Button>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
|||||||
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
58
apps/web/src/components/forms/2fa/authenticator-app.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>Authenticator app</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||||
|
Create one-time passwords that serve as a secondary authentication method for confirming
|
||||||
|
your identity when requested during the sign-in process.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{isTwoFactorEnabled ? (
|
||||||
|
<Button variant="destructive" onClick={() => setModalState('disable')} size="sm">
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button onClick={() => setModalState('enable')} size="sm">
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<EnableAuthenticatorAppDialog
|
||||||
|
key={isEnableDialogOpen ? 'open' : 'closed'}
|
||||||
|
open={isEnableDialogOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<DisableAuthenticatorAppDialog
|
||||||
|
key={isDisableDialogOpen ? 'open' : 'closed'}
|
||||||
|
open={isDisableDialogOpen}
|
||||||
|
onOpenChange={(open) => !open && setModalState(null)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<TDisableTwoFactorAuthenticationForm>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Disable Authenticator App</DialogTitle>
|
||||||
|
|
||||||
|
<DialogDescription>
|
||||||
|
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.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<Form {...disableTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={disableTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onDisableTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={disableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="backupCode"
|
||||||
|
control={disableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Backup Code</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="text" value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="destructive"
|
||||||
|
loading={isDisableTwoFactorAuthenticationSubmitting}
|
||||||
|
>
|
||||||
|
Disable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -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<typeof ZSetupTwoFactorAuthenticationForm>;
|
||||||
|
|
||||||
|
export const ZEnableTwoFactorAuthenticationForm = z.object({
|
||||||
|
token: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type TEnableTwoFactorAuthenticationForm = z.infer<typeof ZEnableTwoFactorAuthenticationForm>;
|
||||||
|
|
||||||
|
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<TSetupTwoFactorAuthenticationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
password: '',
|
||||||
|
},
|
||||||
|
resolver: zodResolver(ZSetupTwoFactorAuthenticationForm),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { isSubmitting: isSetupTwoFactorAuthenticationSubmitting } =
|
||||||
|
setupTwoFactorAuthenticationForm.formState;
|
||||||
|
|
||||||
|
const enableTwoFactorAuthenticationForm = useForm<TEnableTwoFactorAuthenticationForm>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Enable Authenticator App</DialogTitle>
|
||||||
|
|
||||||
|
{step === 'setup' && (
|
||||||
|
<DialogDescription>
|
||||||
|
To enable two-factor authentication, please enter your password below.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'view' && (
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match(step)
|
||||||
|
.with('setup', () => {
|
||||||
|
return (
|
||||||
|
<Form {...setupTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={setupTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onSetupTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={setupTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSetupTwoFactorAuthenticationSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with('enable', () => (
|
||||||
|
<Form {...enableTwoFactorAuthenticationForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={enableTwoFactorAuthenticationForm.handleSubmit(
|
||||||
|
onEnableTwoFactorAuthenticationFormSubmit,
|
||||||
|
)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
To enable two-factor authentication, scan the following QR code using your
|
||||||
|
authenticator app.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex h-36 justify-center"
|
||||||
|
dangerouslySetInnerHTML={{
|
||||||
|
__html: renderSVG(setupTwoFactorAuthenticationData?.uri ?? ''),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
If your authenticator app does not support QR codes, you can use the following
|
||||||
|
code instead:
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="bg-muted/60 text-muted-foreground rounded-lg p-2 text-center font-mono tracking-widest">
|
||||||
|
{setupTwoFactorAuthenticationData?.secret}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Once you have scanned the QR code or entered the code manually, enter the code
|
||||||
|
provided by your authenticator app below.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<FormField
|
||||||
|
name="token"
|
||||||
|
control={enableTwoFactorAuthenticationForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Token</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input {...field} type="text" value={field.value ?? ''} />
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isEnableTwoFactorAuthenticationSubmitting}>
|
||||||
|
Enable 2FA
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
))
|
||||||
|
.with('view', () => (
|
||||||
|
<div>
|
||||||
|
{enableTwoFactorAuthenticationData?.recoveryCodes && (
|
||||||
|
<RecoveryCodeList recoveryCodes={enableTwoFactorAuthenticationData.recoveryCodes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex w-full flex-row-reverse items-center justify-between">
|
||||||
|
<Button type="button" onClick={() => onCompleteClick()}>
|
||||||
|
Complete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
57
apps/web/src/components/forms/2fa/recovery-code-list.tsx
Normal file
@ -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 (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
{recoveryCodes.map((code) => (
|
||||||
|
<div
|
||||||
|
key={code}
|
||||||
|
className="bg-muted text-muted-foreground relative rounded-lg p-4 font-mono md:text-center"
|
||||||
|
>
|
||||||
|
<span>{code}</span>
|
||||||
|
|
||||||
|
<div className="absolute inset-y-0 right-4 flex items-center justify-center">
|
||||||
|
<button
|
||||||
|
className="opacity-60 hover:opacity-80"
|
||||||
|
onClick={() => void onCopyRecoveryCodeClick(code)}
|
||||||
|
>
|
||||||
|
<Copy className="h-5 w-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
43
apps/web/src/components/forms/2fa/recovery-codes.tsx
Normal file
@ -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 (
|
||||||
|
<>
|
||||||
|
<div className="mt-4 flex flex-col justify-between gap-4 rounded-lg border p-4 md:flex-row md:items-center md:gap-8">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p>Recovery Codes</p>
|
||||||
|
|
||||||
|
<p className="text-muted-foreground mt-2 max-w-[50ch] text-sm">
|
||||||
|
Recovery codes are used to access your account in the event that you lose access to your
|
||||||
|
authenticator app.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<Button onClick={() => setIsOpen(true)} disabled={!isTwoFactorEnabled} size="sm">
|
||||||
|
View Codes
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ViewRecoveryCodesDialog
|
||||||
|
key={isOpen ? 'open' : 'closed'}
|
||||||
|
open={isOpen}
|
||||||
|
onOpenChange={setIsOpen}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
151
apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx
Normal file
@ -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<typeof ZViewRecoveryCodesForm>;
|
||||||
|
|
||||||
|
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<TViewRecoveryCodesForm>({
|
||||||
|
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 (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="w-full max-w-xl md:max-w-xl lg:max-w-xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>View Recovery Codes</DialogTitle>
|
||||||
|
|
||||||
|
{step === 'authenticate' && (
|
||||||
|
<DialogDescription>
|
||||||
|
To view your recovery codes, please enter your password below.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{step === 'view' && (
|
||||||
|
<DialogDescription>
|
||||||
|
Your recovery codes are listed below. Please store them in a safe place.
|
||||||
|
</DialogDescription>
|
||||||
|
)}
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{match(step)
|
||||||
|
.with('authenticate', () => {
|
||||||
|
return (
|
||||||
|
<Form {...viewRecoveryCodesForm}>
|
||||||
|
<form
|
||||||
|
onSubmit={viewRecoveryCodesForm.handleSubmit(onViewRecoveryCodesFormSubmit)}
|
||||||
|
className="flex flex-col gap-y-4"
|
||||||
|
>
|
||||||
|
<FormField
|
||||||
|
name="password"
|
||||||
|
control={viewRecoveryCodesForm.control}
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel className="text-muted-foreground">Password</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
type="password"
|
||||||
|
autoComplete="current-password"
|
||||||
|
value={field.value ?? ''}
|
||||||
|
/>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
<Button type="button" variant="ghost" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isViewRecoveryCodesSubmitting}>
|
||||||
|
Continue
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Form>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.with('view', () => (
|
||||||
|
<div>
|
||||||
|
{viewRecoveryCodesData?.recoveryCodes && (
|
||||||
|
<RecoveryCodeList recoveryCodes={viewRecoveryCodesData.recoveryCodes} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-row-reverse items-center justify-between">
|
||||||
|
<Button onClick={() => onOpenChange(false)}>Complete</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
.exhaustive()}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
};
|
||||||
@ -3,7 +3,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { Eye, EyeOff } from 'lucide-react';
|
|
||||||
import { signIn } from 'next-auth/react';
|
import { signIn } from 'next-auth/react';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { FcGoogle } from 'react-icons/fc';
|
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 { ErrorCode, isErrorCode } from '@documenso/lib/next-auth/error-codes';
|
||||||
import { cn } from '@documenso/ui/lib/utils';
|
import { cn } from '@documenso/ui/lib/utils';
|
||||||
import { Button } from '@documenso/ui/primitives/button';
|
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 { 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 { Label } from '@documenso/ui/primitives/label';
|
||||||
import { useToast } from '@documenso/ui/primitives/use-toast';
|
import { useToast } from '@documenso/ui/primitives/use-toast';
|
||||||
|
|
||||||
const ERROR_MESSAGES = {
|
const ERROR_MESSAGES: Partial<Record<keyof typeof ErrorCode, string>> = {
|
||||||
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
[ErrorCode.CREDENTIALS_NOT_FOUND]: 'The email or password provided is incorrect',
|
||||||
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
[ErrorCode.INCORRECT_EMAIL_PASSWORD]: 'The email or password provided is incorrect',
|
||||||
[ErrorCode.USER_MISSING_PASSWORD]:
|
[ErrorCode.USER_MISSING_PASSWORD]:
|
||||||
'This account appears to be using a social login method, please sign in using that method',
|
'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';
|
const LOGIN_REDIRECT_PATH = '/documents';
|
||||||
|
|
||||||
export const ZSignInFormSchema = z.object({
|
export const ZSignInFormSchema = z.object({
|
||||||
email: z.string().email().min(1),
|
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<typeof ZSignInFormSchema>;
|
export type TSignInFormSchema = z.infer<typeof ZSignInFormSchema>;
|
||||||
@ -39,33 +45,84 @@ export type SignInFormProps = {
|
|||||||
|
|
||||||
export const SignInForm = ({ className }: SignInFormProps) => {
|
export const SignInForm = ({ className }: SignInFormProps) => {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [isTwoFactorAuthenticationDialogOpen, setIsTwoFactorAuthenticationDialogOpen] =
|
||||||
|
useState(false);
|
||||||
|
|
||||||
|
const [twoFactorAuthenticationMethod, setTwoFactorAuthenticationMethod] = useState<
|
||||||
|
'totp' | 'backup'
|
||||||
|
>('totp');
|
||||||
|
|
||||||
const {
|
const {
|
||||||
register,
|
register,
|
||||||
handleSubmit,
|
handleSubmit,
|
||||||
|
setValue,
|
||||||
formState: { errors, isSubmitting },
|
formState: { errors, isSubmitting },
|
||||||
} = useForm<TSignInFormSchema>({
|
} = useForm<TSignInFormSchema>({
|
||||||
values: {
|
values: {
|
||||||
email: '',
|
email: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
totpCode: '',
|
||||||
|
backupCode: '',
|
||||||
},
|
},
|
||||||
resolver: zodResolver(ZSignInFormSchema),
|
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 {
|
try {
|
||||||
const result = await signIn('credentials', {
|
const credentials: Record<string, string> = {
|
||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (totpCode) {
|
||||||
|
credentials.totpCode = totpCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (backupCode) {
|
||||||
|
credentials.backupCode = backupCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await signIn('credentials', {
|
||||||
|
...credentials,
|
||||||
|
|
||||||
callbackUrl: LOGIN_REDIRECT_PATH,
|
callbackUrl: LOGIN_REDIRECT_PATH,
|
||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result?.error && isErrorCode(result.error)) {
|
if (result?.error && isErrorCode(result.error)) {
|
||||||
|
if (result.error === TwoFactorEnabledErrorCode) {
|
||||||
|
setIsTwoFactorAuthenticationDialogOpen(true);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorMessage = ERROR_MESSAGES[result.error];
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
description: ERROR_MESSAGES[result.error],
|
title: 'Unable to sign in',
|
||||||
|
description: errorMessage ?? 'An unknown error occurred',
|
||||||
});
|
});
|
||||||
|
|
||||||
return;
|
return;
|
||||||
@ -118,31 +175,14 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<span>Password</span>
|
<span>Password</span>
|
||||||
</Label>
|
</Label>
|
||||||
|
|
||||||
<div className="relative">
|
<PasswordInput
|
||||||
<Input
|
id="password"
|
||||||
id="password"
|
minLength={6}
|
||||||
type={showPassword ? 'text' : 'password'}
|
maxLength={72}
|
||||||
minLength={6}
|
className="bg-background mt-2"
|
||||||
maxLength={72}
|
autoComplete="current-password"
|
||||||
autoComplete="current-password"
|
{...register('password')}
|
||||||
className="bg-background mt-2 pr-10"
|
/>
|
||||||
{...register('password')}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
type="button"
|
|
||||||
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
|
||||||
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
|
||||||
onClick={() => setShowPassword((show) => !show)}
|
|
||||||
>
|
|
||||||
{showPassword ? (
|
|
||||||
<EyeOff className="text-muted-foreground h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Eye className="text-muted-foreground h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
<FormErrorMessage className="mt-1.5" error={errors.password} />
|
||||||
</div>
|
</div>
|
||||||
@ -173,6 +213,67 @@ export const SignInForm = ({ className }: SignInFormProps) => {
|
|||||||
<FcGoogle className="mr-2 h-5 w-5" />
|
<FcGoogle className="mr-2 h-5 w-5" />
|
||||||
Google
|
Google
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
|
<Dialog
|
||||||
|
open={isTwoFactorAuthenticationDialogOpen}
|
||||||
|
onOpenChange={onCloseTwoFactorAuthenticationDialog}
|
||||||
|
>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Two-Factor Authentication</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)}>
|
||||||
|
{twoFactorAuthenticationMethod === 'totp' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="totpCode" className="text-muted-forground">
|
||||||
|
Authentication Token
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="totpCode"
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('totpCode')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.totpCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{twoFactorAuthenticationMethod === 'backup' && (
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="backupCode" className="text-muted-forground">
|
||||||
|
Backup Code
|
||||||
|
</Label>
|
||||||
|
|
||||||
|
<Input
|
||||||
|
id="backupCode"
|
||||||
|
type="text"
|
||||||
|
className="bg-background mt-2"
|
||||||
|
{...register('backupCode')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<FormErrorMessage className="mt-1.5" error={errors.backupCode} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-center justify-between">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onToggleTwoFactorAuthenticationMethodClick}
|
||||||
|
>
|
||||||
|
{twoFactorAuthenticationMethod === 'totp' ? 'Use Backup Code' : 'Use Authenticator'}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button type="submit" loading={isSubmitting}>
|
||||||
|
Sign In
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
480
package-lock.json
generated
480
package-lock.json
generated
@ -111,6 +111,7 @@
|
|||||||
"sharp": "0.32.5",
|
"sharp": "0.32.5",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"typescript": "5.2.2",
|
"typescript": "5.2.2",
|
||||||
|
"uqr": "^0.1.2",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -2860,6 +2861,465 @@
|
|||||||
"node": ">= 10"
|
"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": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
|
||||||
@ -14300,6 +14760,15 @@
|
|||||||
"node": ">=8"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
||||||
@ -18457,6 +18926,11 @@
|
|||||||
"browserslist": ">= 4.21.0"
|
"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": {
|
"node_modules/uri-js": {
|
||||||
"version": "4.4.1",
|
"version": "4.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
|
||||||
@ -19781,6 +20255,8 @@
|
|||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/signing": "*",
|
"@documenso/signing": "*",
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
|
"@noble/ciphers": "0.4.0",
|
||||||
|
"@noble/hashes": "1.3.2",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@scure/base": "^1.1.3",
|
"@scure/base": "^1.1.3",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
@ -19790,6 +20266,7 @@
|
|||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.3",
|
||||||
|
"oslo": "^0.17.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"remeda": "^1.27.1",
|
"remeda": "^1.27.1",
|
||||||
@ -19891,7 +20368,8 @@
|
|||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
},
|
},
|
||||||
"packages/tsconfig": {
|
"packages/tsconfig": {
|
||||||
"name": "@documenso/tsconfig",
|
"name": "@documenso/tsconfig",
|
||||||
|
|||||||
1
packages/lib/constants/crypto.ts
Normal file
1
packages/lib/constants/crypto.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const DOCUMENSO_ENCRYPTION_KEY = process.env.NEXT_PRIVATE_ENCRYPTION_KEY;
|
||||||
@ -10,6 +10,8 @@ import GoogleProvider from 'next-auth/providers/google';
|
|||||||
|
|
||||||
import { prisma } from '@documenso/prisma';
|
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 { getUserByEmail } from '../server-only/user/get-user-by-email';
|
||||||
import { ErrorCode } from './error-codes';
|
import { ErrorCode } from './error-codes';
|
||||||
|
|
||||||
@ -25,13 +27,19 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
credentials: {
|
credentials: {
|
||||||
email: { label: 'Email', type: 'email' },
|
email: { label: 'Email', type: 'email' },
|
||||||
password: { label: 'Password', type: 'password' },
|
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) => {
|
authorize: async (credentials, _req) => {
|
||||||
if (!credentials) {
|
if (!credentials) {
|
||||||
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
throw new Error(ErrorCode.CREDENTIALS_NOT_FOUND);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { email, password } = credentials;
|
const { email, password, backupCode, totpCode } = credentials;
|
||||||
|
|
||||||
const user = await getUserByEmail({ email }).catch(() => {
|
const user = await getUserByEmail({ email }).catch(() => {
|
||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
||||||
@ -47,6 +55,20 @@ export const NEXT_AUTH_OPTIONS: AuthOptions = {
|
|||||||
throw new Error(ErrorCode.INCORRECT_EMAIL_PASSWORD);
|
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 {
|
return {
|
||||||
id: Number(user.id),
|
id: Number(user.id),
|
||||||
email: user.email,
|
email: user.email,
|
||||||
|
|||||||
@ -8,4 +8,15 @@ export const ErrorCode = {
|
|||||||
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
INCORRECT_EMAIL_PASSWORD: 'INCORRECT_EMAIL_PASSWORD',
|
||||||
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
USER_MISSING_PASSWORD: 'USER_MISSING_PASSWORD',
|
||||||
CREDENTIALS_NOT_FOUND: 'CREDENTIALS_NOT_FOUND',
|
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;
|
} as const;
|
||||||
|
|||||||
@ -25,6 +25,8 @@
|
|||||||
"@documenso/prisma": "*",
|
"@documenso/prisma": "*",
|
||||||
"@documenso/signing": "*",
|
"@documenso/signing": "*",
|
||||||
"@next-auth/prisma-adapter": "1.0.7",
|
"@next-auth/prisma-adapter": "1.0.7",
|
||||||
|
"@noble/ciphers": "0.4.0",
|
||||||
|
"@noble/hashes": "1.3.2",
|
||||||
"@pdf-lib/fontkit": "^1.1.1",
|
"@pdf-lib/fontkit": "^1.1.1",
|
||||||
"@scure/base": "^1.1.3",
|
"@scure/base": "^1.1.3",
|
||||||
"@sindresorhus/slugify": "^2.2.1",
|
"@sindresorhus/slugify": "^2.2.1",
|
||||||
@ -34,6 +36,7 @@
|
|||||||
"nanoid": "^4.0.2",
|
"nanoid": "^4.0.2",
|
||||||
"next": "14.0.0",
|
"next": "14.0.0",
|
||||||
"next-auth": "4.24.3",
|
"next-auth": "4.24.3",
|
||||||
|
"oslo": "^0.17.0",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"react": "18.2.0",
|
"react": "18.2.0",
|
||||||
"remeda": "^1.27.1",
|
"remeda": "^1.27.1",
|
||||||
|
|||||||
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
48
packages/lib/server-only/2fa/disable-2fa.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
47
packages/lib/server-only/2fa/enable-2fa.ts
Normal file
@ -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 };
|
||||||
|
};
|
||||||
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
38
packages/lib/server-only/2fa/get-backup-code.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
17
packages/lib/server-only/2fa/is-2fa-availble.ts
Normal file
@ -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'
|
||||||
|
);
|
||||||
|
};
|
||||||
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
76
packages/lib/server-only/2fa/setup-2fa.ts
Normal file
@ -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,
|
||||||
|
};
|
||||||
|
};
|
||||||
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
35
packages/lib/server-only/2fa/validate-2fa.ts
Normal file
@ -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);
|
||||||
|
};
|
||||||
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
33
packages/lib/server-only/2fa/verify-2fa-token.ts
Normal file
@ -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;
|
||||||
|
};
|
||||||
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
18
packages/lib/server-only/2fa/verify-backup-code.ts
Normal file
@ -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);
|
||||||
|
};
|
||||||
@ -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';
|
import { SALT_ROUNDS } from '../../constants/auth';
|
||||||
|
|
||||||
@ -8,3 +8,7 @@ import { SALT_ROUNDS } from '../../constants/auth';
|
|||||||
export const hashSync = (password: string) => {
|
export const hashSync = (password: string) => {
|
||||||
return bcryptHashSync(password, SALT_ROUNDS);
|
return bcryptHashSync(password, SALT_ROUNDS);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const compareSync = (password: string, hash: string) => {
|
||||||
|
return bcryptCompareSync(password, hash);
|
||||||
|
};
|
||||||
|
|||||||
32
packages/lib/universal/crypto.ts
Normal file
32
packages/lib/universal/crypto.ts
Normal file
@ -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);
|
||||||
|
};
|
||||||
@ -0,0 +1,4 @@
|
|||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "User" ADD COLUMN "twoFactorBackupCodes" TEXT,
|
||||||
|
ADD COLUMN "twoFactorEnabled" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
ADD COLUMN "twoFactorSecret" TEXT;
|
||||||
@ -19,24 +19,27 @@ enum Role {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id Int @id @default(autoincrement())
|
id Int @id @default(autoincrement())
|
||||||
name String?
|
name String?
|
||||||
email String @unique
|
email String @unique
|
||||||
emailVerified DateTime?
|
emailVerified DateTime?
|
||||||
password String?
|
password String?
|
||||||
source String?
|
source String?
|
||||||
signature String?
|
signature String?
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @default(now()) @updatedAt
|
updatedAt DateTime @default(now()) @updatedAt
|
||||||
lastSignedIn DateTime @default(now())
|
lastSignedIn DateTime @default(now())
|
||||||
roles Role[] @default([USER])
|
roles Role[] @default([USER])
|
||||||
identityProvider IdentityProvider @default(DOCUMENSO)
|
identityProvider IdentityProvider @default(DOCUMENSO)
|
||||||
accounts Account[]
|
accounts Account[]
|
||||||
sessions Session[]
|
sessions Session[]
|
||||||
Document Document[]
|
Document Document[]
|
||||||
Subscription Subscription?
|
Subscription Subscription?
|
||||||
PasswordResetToken PasswordResetToken[]
|
PasswordResetToken PasswordResetToken[]
|
||||||
VerificationToken VerificationToken[]
|
twoFactorSecret String?
|
||||||
|
twoFactorEnabled Boolean @default(false)
|
||||||
|
twoFactorBackupCodes String?
|
||||||
|
VerificationToken VerificationToken[]
|
||||||
|
|
||||||
@@index([email])
|
@@index([email])
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,5 +21,6 @@
|
|||||||
"superjson": "^1.13.1",
|
"superjson": "^1.13.1",
|
||||||
"ts-pattern": "^5.0.5",
|
"ts-pattern": "^5.0.5",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
}
|
},
|
||||||
|
"devDependencies": {}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
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 { createUser } from '@documenso/lib/server-only/user/create-user';
|
||||||
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
import { sendConfirmationToken } from '@documenso/lib/server-only/user/send-confirmation-token';
|
||||||
|
|
||||||
import { procedure, router } from '../trpc';
|
import { authenticatedProcedure, procedure, router } from '../trpc';
|
||||||
import { ZSignUpMutationSchema } from './schema';
|
import { ZSignUpMutationSchema, ZVerifyPasswordMutationSchema } from './schema';
|
||||||
|
|
||||||
export const authRouter = router({
|
export const authRouter = router({
|
||||||
signup: procedure.input(ZSignUpMutationSchema).mutation(async ({ input }) => {
|
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;
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -8,3 +8,5 @@ export const ZSignUpMutationSchema = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
export type TSignUpMutationSchema = z.infer<typeof ZSignUpMutationSchema>;
|
||||||
|
|
||||||
|
export const ZVerifyPasswordMutationSchema = ZSignUpMutationSchema.pick({ password: true });
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { profileRouter } from './profile-router/router';
|
|||||||
import { shareLinkRouter } from './share-link-router/router';
|
import { shareLinkRouter } from './share-link-router/router';
|
||||||
import { singleplayerRouter } from './singleplayer-router/router';
|
import { singleplayerRouter } from './singleplayer-router/router';
|
||||||
import { router } from './trpc';
|
import { router } from './trpc';
|
||||||
|
import { twoFactorAuthenticationRouter } from './two-factor-authentication-router/router';
|
||||||
|
|
||||||
export const appRouter = router({
|
export const appRouter = router({
|
||||||
auth: authRouter,
|
auth: authRouter,
|
||||||
@ -15,6 +16,7 @@ export const appRouter = router({
|
|||||||
admin: adminRouter,
|
admin: adminRouter,
|
||||||
shareLink: shareLinkRouter,
|
shareLink: shareLinkRouter,
|
||||||
singleplayer: singleplayerRouter,
|
singleplayer: singleplayerRouter,
|
||||||
|
twoFactorAuthentication: twoFactorAuthenticationRouter,
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AppRouter = typeof appRouter;
|
export type AppRouter = typeof appRouter;
|
||||||
|
|||||||
105
packages/trpc/server/two-factor-authentication-router/router.ts
Normal file
105
packages/trpc/server/two-factor-authentication-router/router.ts
Normal file
@ -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.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
});
|
||||||
@ -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<typeof ZViewRecoveryCodesMutationSchema>;
|
||||||
1
packages/tsconfig/process-env.d.ts
vendored
1
packages/tsconfig/process-env.d.ts
vendored
@ -7,6 +7,7 @@ declare namespace NodeJS {
|
|||||||
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
NEXT_PRIVATE_GOOGLE_CLIENT_SECRET?: string;
|
||||||
|
|
||||||
NEXT_PRIVATE_DATABASE_URL: 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_MONTHLY_PRICE_ID: string;
|
||||||
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
NEXT_PUBLIC_STRIPE_COMMUNITY_PLAN_YEARLY_PRICE_ID: string;
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
import * as React from 'react';
|
import * as React from 'react';
|
||||||
|
|
||||||
|
import { Eye, EyeOff } from 'lucide-react';
|
||||||
|
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
|
import { Button } from './button';
|
||||||
|
|
||||||
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
export type InputProps = React.InputHTMLAttributes<HTMLInputElement>;
|
||||||
|
|
||||||
@ -25,4 +28,38 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|||||||
|
|
||||||
Input.displayName = 'Input';
|
Input.displayName = 'Input';
|
||||||
|
|
||||||
export { Input };
|
const PasswordInput = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, ...props }, ref) => {
|
||||||
|
const [showPassword, setShowPassword] = React.useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type={showPassword ? 'text' : 'password'}
|
||||||
|
className={cn('pr-10', className)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
type="button"
|
||||||
|
className="absolute right-0 top-0 flex h-full items-center justify-center pr-3"
|
||||||
|
aria-label={showPassword ? 'Mask password' : 'Reveal password'}
|
||||||
|
onClick={() => setShowPassword((show) => !show)}
|
||||||
|
>
|
||||||
|
{showPassword ? (
|
||||||
|
<EyeOff aria-hidden className="text-muted-foreground h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Eye aria-hidden className="text-muted-foreground h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
PasswordInput.displayName = 'Input';
|
||||||
|
|
||||||
|
export { Input, PasswordInput };
|
||||||
|
|||||||
@ -33,6 +33,7 @@
|
|||||||
"globalDependencies": ["**/.env.*local"],
|
"globalDependencies": ["**/.env.*local"],
|
||||||
"globalEnv": [
|
"globalEnv": [
|
||||||
"APP_VERSION",
|
"APP_VERSION",
|
||||||
|
"NEXT_PRIVATE_ENCRYPTION_KEY",
|
||||||
"NEXTAUTH_URL",
|
"NEXTAUTH_URL",
|
||||||
"NEXTAUTH_SECRET",
|
"NEXTAUTH_SECRET",
|
||||||
"NEXT_PUBLIC_PROJECT",
|
"NEXT_PUBLIC_PROJECT",
|
||||||
|
|||||||
Reference in New Issue
Block a user