Skip to content

Commit

Permalink
feat: disable 2fa with backup codes (documenso#1314)
Browse files Browse the repository at this point in the history
Allow disabling two-factor authentication (2FA) by using either their
authenticator app (TOTP) or a backup code.
  • Loading branch information
ephraimduncan authored Aug 29, 2024
1 parent 81479b5 commit 9e714d6
Show file tree
Hide file tree
Showing 6 changed files with 122 additions and 58 deletions.
114 changes: 82 additions & 32 deletions apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ import { trpc } from '@documenso/trpc/react';
import { Button } from '@documenso/ui/primitives/button';
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
Expand All @@ -28,13 +27,16 @@ import {
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@documenso/ui/primitives/form/form';
import { Input } from '@documenso/ui/primitives/input';
import { PinInput, PinInputGroup, PinInputSlot } from '@documenso/ui/primitives/pin-input';
import { useToast } from '@documenso/ui/primitives/use-toast';

export const ZDisable2FAForm = z.object({
token: z.string(),
totpCode: z.string().trim().optional(),
backupCode: z.string().trim().optional(),
});

export type TDisable2FAForm = z.infer<typeof ZDisable2FAForm>;
Expand All @@ -46,21 +48,43 @@ export const DisableAuthenticatorAppDialog = () => {
const { toast } = useToast();

const [isOpen, setIsOpen] = useState(false);
const [twoFactorDisableMethod, setTwoFactorDisableMethod] = useState<'totp' | 'backup'>('totp');

const { mutateAsync: disable2FA } = trpc.twoFactorAuthentication.disable.useMutation();

const disable2FAForm = useForm<TDisable2FAForm>({
defaultValues: {
token: '',
totpCode: '',
backupCode: '',
},
resolver: zodResolver(ZDisable2FAForm),
});

const onCloseTwoFactorDisableDialog = () => {
disable2FAForm.reset();

setIsOpen(!isOpen);
};

const onToggleTwoFactorDisableMethodClick = () => {
const method = twoFactorDisableMethod === 'totp' ? 'backup' : 'totp';

if (method === 'totp') {
disable2FAForm.setValue('backupCode', '');
}

if (method === 'backup') {
disable2FAForm.setValue('totpCode', '');
}

setTwoFactorDisableMethod(method);
};

const { isSubmitting: isDisable2FASubmitting } = disable2FAForm.formState;

const onDisable2FAFormSubmit = async ({ token }: TDisable2FAForm) => {
const onDisable2FAFormSubmit = async ({ totpCode, backupCode }: TDisable2FAForm) => {
try {
await disable2FA({ token });
await disable2FA({ totpCode, backupCode });

toast({
title: _(msg`Two-factor authentication disabled`),
Expand All @@ -70,7 +94,7 @@ export const DisableAuthenticatorAppDialog = () => {
});

flushSync(() => {
setIsOpen(false);
onCloseTwoFactorDisableDialog();
});

router.refresh();
Expand All @@ -86,7 +110,7 @@ export const DisableAuthenticatorAppDialog = () => {
};

return (
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<Dialog open={isOpen} onOpenChange={onCloseTwoFactorDisableDialog}>
<DialogTrigger asChild={true}>
<Button className="flex-shrink-0" variant="destructive">
<Trans>Disable 2FA</Trans>
Expand All @@ -110,33 +134,59 @@ export const DisableAuthenticatorAppDialog = () => {
<Form {...disable2FAForm}>
<form onSubmit={disable2FAForm.handleSubmit(onDisable2FAFormSubmit)}>
<fieldset className="flex flex-col gap-y-4" disabled={isDisable2FASubmitting}>
<FormField
name="token"
control={disable2FAForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{twoFactorDisableMethod === 'totp' && (
<FormField
name="totpCode"
control={disable2FAForm.control}
render={({ field }) => (
<FormItem>
<FormControl>
<PinInput {...field} value={field.value ?? ''} maxLength={6}>
{Array(6)
.fill(null)
.map((_, i) => (
<PinInputGroup key={i}>
<PinInputSlot index={i} />
</PinInputGroup>
))}
</PinInput>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}

{twoFactorDisableMethod === 'backup' && (
<FormField
control={disable2FAForm.control}
name="backupCode"
render={({ field }) => (
<FormItem>
<FormLabel>
<Trans>Backup Code</Trans>
</FormLabel>
<FormControl>
<Input type="text" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}

<DialogFooter>
<DialogClose asChild>
<Button type="button" variant="secondary">
<Trans>Cancel</Trans>
</Button>
</DialogClose>
<Button
type="button"
variant="secondary"
onClick={onToggleTwoFactorDisableMethodClick}
>
{twoFactorDisableMethod === 'totp' ? (
<Trans>Use Backup Code</Trans>
) : (
<Trans>Use Authenticator</Trans>
)}
</Button>

<Button type="submit" variant="destructive" loading={isDisable2FASubmitting}>
<Trans>Disable 2FA</Trans>
Expand Down
20 changes: 14 additions & 6 deletions packages/lib/server-only/2fa/disable-2fa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,25 +2,33 @@ import { prisma } from '@documenso/prisma';
import type { User } from '@documenso/prisma/client';
import { UserSecurityAuditLogType } from '@documenso/prisma/client';

import { AppError } from '../../errors/app-error';
import { AppError, AppErrorCode } from '../../errors/app-error';
import type { RequestMetadata } from '../../universal/extract-request-metadata';
import { validateTwoFactorAuthentication } from './validate-2fa';

type DisableTwoFactorAuthenticationOptions = {
user: User;
token: string;
totpCode?: string;
backupCode?: string;
requestMetadata?: RequestMetadata;
};

export const disableTwoFactorAuthentication = async ({
token,
totpCode,
backupCode,
user,
requestMetadata,
}: DisableTwoFactorAuthenticationOptions) => {
let isValid = await validateTwoFactorAuthentication({ totpCode: token, user });
let isValid = false;

if (!isValid) {
isValid = await validateTwoFactorAuthentication({ backupCode: token, user });
if (!totpCode && !backupCode) {
throw new AppError(AppErrorCode.INVALID_REQUEST);
}

if (totpCode) {
isValid = await validateTwoFactorAuthentication({ totpCode, user });
} else if (backupCode) {
isValid = await validateTwoFactorAuthentication({ backupCode, user });
}

if (!isValid) {
Expand Down
20 changes: 11 additions & 9 deletions packages/lib/translations/de/web.po
Original file line number Diff line number Diff line change
Expand Up @@ -607,6 +607,7 @@ msgstr ""
msgid "Background Color"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:167
#: apps/web/src/components/forms/signin.tsx:451
msgid "Backup Code"
msgstr ""
Expand Down Expand Up @@ -684,7 +685,6 @@ msgstr ""
#: apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx:278
#: apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx:162
#: apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx:187
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:137
#: apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx:257
#: apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx:163
#: apps/web/src/components/templates/manage-public-template-dialog.tsx:452
Expand Down Expand Up @@ -1143,9 +1143,9 @@ msgstr ""
msgid "Disable"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:92
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:99
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:142
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:116
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:123
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:192
msgid "Disable 2FA"
msgstr ""

Expand Down Expand Up @@ -2279,7 +2279,7 @@ msgstr ""
msgid "Please note that you will lose access to all documents associated with this team & all the members will be removed and notified"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:103
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:127
msgid "Please provide a token from the authenticator, or a backup code. If you do not have a backup code available, please contact support."
msgstr ""

Expand Down Expand Up @@ -3453,15 +3453,15 @@ msgstr ""
msgid "Two-Factor Authentication"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:66
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:90
msgid "Two-factor authentication disabled"
msgstr ""

#: apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx:94
msgid "Two-factor authentication enabled"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:68
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:92
msgid "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."
msgstr ""

Expand Down Expand Up @@ -3506,7 +3506,7 @@ msgstr ""
msgid "Unable to delete team"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:79
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:103
msgid "Unable to disable two-factor authentication"
msgstr ""

Expand Down Expand Up @@ -3654,10 +3654,12 @@ msgstr ""
msgid "Uploaded file not an allowed file type"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:187
#: apps/web/src/components/forms/signin.tsx:471
msgid "Use Authenticator"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:185
#: apps/web/src/components/forms/signin.tsx:469
msgid "Use Backup Code"
msgstr ""
Expand Down Expand Up @@ -3944,7 +3946,7 @@ msgstr ""
msgid "We were unable to create a checkout session. Please try again, or contact support"
msgstr ""

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:81
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:105
msgid "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."
msgstr ""

Expand Down
20 changes: 11 additions & 9 deletions packages/lib/translations/en/web.po
Original file line number Diff line number Diff line change
Expand Up @@ -602,6 +602,7 @@ msgstr "Back to Documents"
msgid "Background Color"
msgstr "Background Color"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:167
#: apps/web/src/components/forms/signin.tsx:451
msgid "Backup Code"
msgstr "Backup Code"
Expand Down Expand Up @@ -679,7 +680,6 @@ msgstr "By enabling 2FA, you will be required to enter a code from your authenti
#: apps/web/src/components/(teams)/dialogs/transfer-team-dialog.tsx:278
#: apps/web/src/components/(teams)/dialogs/update-team-email-dialog.tsx:162
#: apps/web/src/components/(teams)/dialogs/update-team-member-dialog.tsx:187
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:137
#: apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx:257
#: apps/web/src/components/forms/2fa/view-recovery-codes-dialog.tsx:163
#: apps/web/src/components/templates/manage-public-template-dialog.tsx:452
Expand Down Expand Up @@ -1138,9 +1138,9 @@ msgstr "Direct template link usage exceeded ({0}/{1})"
msgid "Disable"
msgstr "Disable"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:92
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:99
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:142
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:116
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:123
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:192
msgid "Disable 2FA"
msgstr "Disable 2FA"

Expand Down Expand Up @@ -2274,7 +2274,7 @@ msgstr "Please note that this action is irreversible. Once confirmed, your webho
msgid "Please note that you will lose access to all documents associated with this team & all the members will be removed and notified"
msgstr "Please note that you will lose access to all documents associated with this team & all the members will be removed and notified"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:103
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:127
msgid "Please provide a token from the authenticator, or a backup code. If you do not have a backup code available, please contact support."
msgstr "Please provide a token from the authenticator, or a backup code. If you do not have a backup code available, please contact support."

Expand Down Expand Up @@ -3448,15 +3448,15 @@ msgstr "Two factor authentication recovery codes are used to access your account
msgid "Two-Factor Authentication"
msgstr "Two-Factor Authentication"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:66
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:90
msgid "Two-factor authentication disabled"
msgstr "Two-factor authentication disabled"

#: apps/web/src/components/forms/2fa/enable-authenticator-app-dialog.tsx:94
msgid "Two-factor authentication enabled"
msgstr "Two-factor authentication enabled"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:68
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:92
msgid "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."
msgstr "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."

Expand Down Expand Up @@ -3501,7 +3501,7 @@ msgstr "Unable to delete invitation. Please try again."
msgid "Unable to delete team"
msgstr "Unable to delete team"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:79
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:103
msgid "Unable to disable two-factor authentication"
msgstr "Unable to disable two-factor authentication"

Expand Down Expand Up @@ -3649,10 +3649,12 @@ msgstr "Uploaded file is too small"
msgid "Uploaded file not an allowed file type"
msgstr "Uploaded file not an allowed file type"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:187
#: apps/web/src/components/forms/signin.tsx:471
msgid "Use Authenticator"
msgstr "Use Authenticator"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:185
#: apps/web/src/components/forms/signin.tsx:469
msgid "Use Backup Code"
msgstr "Use Backup Code"
Expand Down Expand Up @@ -3939,7 +3941,7 @@ msgstr "We were unable to copy your recovery code to your clipboard. Please try
msgid "We were unable to create a checkout session. Please try again, or contact support"
msgstr "We were unable to create a checkout session. Please try again, or contact support"

#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:81
#: apps/web/src/components/forms/2fa/disable-authenticator-app-dialog.tsx:105
msgid "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."
msgstr "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."

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ export const twoFactorAuthenticationRouter = router({

return await disableTwoFactorAuthentication({
user,
token: input.token,
totpCode: input.totpCode,
backupCode: input.backupCode,
requestMetadata: extractNextApiRequestMetadata(ctx.req),
});
} catch (err) {
Expand Down
Loading

0 comments on commit 9e714d6

Please sign in to comment.