Skip to content

Commit a308075

Browse files
nicktrnPeerRich
andauthored
feat: 2fa backup codes (#10600)
Co-authored-by: Peer Richelsen <[email protected]>
1 parent efa6d46 commit a308075

File tree

16 files changed

+280
-36
lines changed

16 files changed

+280
-36
lines changed
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import React from "react";
2+
import { useFormContext } from "react-hook-form";
3+
4+
import { useLocale } from "@calcom/lib/hooks/useLocale";
5+
import { Label, TextField } from "@calcom/ui";
6+
7+
export default function TwoFactor({ center = true }) {
8+
const { t } = useLocale();
9+
const methods = useFormContext();
10+
11+
return (
12+
<div className={center ? "mx-auto !mt-0 max-w-sm" : "!mt-0 max-w-sm"}>
13+
<Label className="mt-4">{t("backup_code")}</Label>
14+
15+
<p className="text-subtle mb-4 text-sm">{t("backup_code_instructions")}</p>
16+
17+
<TextField
18+
id="backup-code"
19+
label=""
20+
defaultValue=""
21+
placeholder="XXXXX-XXXXX"
22+
minLength={10} // without dash
23+
maxLength={11} // with dash
24+
required
25+
{...methods.register("backupCode")}
26+
/>
27+
</div>
28+
);
29+
}

apps/web/components/auth/TwoFactor.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useFormContext } from "react-hook-form";
55
import { useLocale } from "@calcom/lib/hooks/useLocale";
66
import { Label, Input } from "@calcom/ui";
77

8-
export default function TwoFactor({ center = true }) {
8+
export default function TwoFactor({ center = true, autoFocus = true }) {
99
const [value, onChange] = useState("");
1010
const { t } = useLocale();
1111
const methods = useFormContext();
@@ -40,7 +40,7 @@ export default function TwoFactor({ center = true }) {
4040
name={`2fa${index + 1}`}
4141
inputMode="decimal"
4242
{...digit}
43-
autoFocus={index === 0}
43+
autoFocus={autoFocus && index === 0}
4444
autoComplete="one-time-code"
4545
/>
4646
))}

apps/web/components/settings/DisableTwoFactorModal.tsx

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
55
import { useLocale } from "@calcom/lib/hooks/useLocale";
66
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField } from "@calcom/ui";
77

8+
import BackupCode from "@components/auth/BackupCode";
89
import TwoFactor from "@components/auth/TwoFactor";
910

1011
import TwoFactorAuthAPI from "./TwoFactorAuthAPI";
@@ -20,6 +21,7 @@ interface DisableTwoFactorAuthModalProps {
2021
}
2122

2223
interface DisableTwoFactorValues {
24+
backupCode: string;
2325
totpCode: string;
2426
password: string;
2527
}
@@ -33,33 +35,45 @@ const DisableTwoFactorAuthModal = ({
3335
}: DisableTwoFactorAuthModalProps) => {
3436
const [isDisabling, setIsDisabling] = useState(false);
3537
const [errorMessage, setErrorMessage] = useState<string | null>(null);
38+
const [twoFactorLostAccess, setTwoFactorLostAccess] = useState(false);
3639
const { t } = useLocale();
3740

3841
const form = useForm<DisableTwoFactorValues>();
3942

40-
async function handleDisable({ totpCode, password }: DisableTwoFactorValues) {
43+
const resetForm = (clearPassword = true) => {
44+
if (clearPassword) form.setValue("password", "");
45+
form.setValue("backupCode", "");
46+
form.setValue("totpCode", "");
47+
setErrorMessage(null);
48+
};
49+
50+
async function handleDisable({ password, totpCode, backupCode }: DisableTwoFactorValues) {
4151
if (isDisabling) {
4252
return;
4353
}
4454
setIsDisabling(true);
4555
setErrorMessage(null);
4656

4757
try {
48-
const response = await TwoFactorAuthAPI.disable(password, totpCode);
58+
const response = await TwoFactorAuthAPI.disable(password, totpCode, backupCode);
4959
if (response.status === 200) {
60+
setTwoFactorLostAccess(false);
61+
resetForm();
5062
onDisable();
5163
return;
5264
}
5365

5466
const body = await response.json();
5567
if (body.error === ErrorCode.IncorrectPassword) {
5668
setErrorMessage(t("incorrect_password"));
57-
}
58-
if (body.error === ErrorCode.SecondFactorRequired) {
69+
} else if (body.error === ErrorCode.SecondFactorRequired) {
5970
setErrorMessage(t("2fa_required"));
60-
}
61-
if (body.error === ErrorCode.IncorrectTwoFactorCode) {
71+
} else if (body.error === ErrorCode.IncorrectTwoFactorCode) {
6272
setErrorMessage(t("incorrect_2fa"));
73+
} else if (body.error === ErrorCode.IncorrectBackupCode) {
74+
setErrorMessage(t("incorrect_backup_code"));
75+
} else if (body.error === ErrorCode.MissingBackupCodes) {
76+
setErrorMessage(t("missing_backup_codes"));
6377
} else {
6478
setErrorMessage(t("something_went_wrong"));
6579
}
@@ -78,19 +92,33 @@ const DisableTwoFactorAuthModal = ({
7892
<div className="mb-8">
7993
{!disablePassword && (
8094
<PasswordField
95+
required
8196
labelProps={{
8297
className: "block text-sm font-medium text-default",
8398
}}
8499
{...form.register("password")}
85100
className="border-default mt-1 block w-full rounded-md border px-3 py-2 text-sm focus:border-black focus:outline-none focus:ring-black"
86101
/>
87102
)}
88-
<TwoFactor center={false} />
103+
{twoFactorLostAccess ? (
104+
<BackupCode center={false} />
105+
) : (
106+
<TwoFactor center={false} autoFocus={false} />
107+
)}
89108

90109
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
91110
</div>
92111

93112
<DialogFooter showDivider className="relative mt-5">
113+
<Button
114+
color="minimal"
115+
className="mr-auto"
116+
onClick={() => {
117+
setTwoFactorLostAccess(!twoFactorLostAccess);
118+
resetForm(false);
119+
}}>
120+
{twoFactorLostAccess ? t("go_back") : t("lost_access")}
121+
</Button>
94122
<Button color="secondary" onClick={onCancel}>
95123
{t("cancel")}
96124
</Button>

apps/web/components/settings/EnableTwoFactorModal.tsx

Lines changed: 76 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { useForm } from "react-hook-form";
55
import { ErrorCode } from "@calcom/features/auth/lib/ErrorCode";
66
import { useCallbackRef } from "@calcom/lib/hooks/useCallbackRef";
77
import { useLocale } from "@calcom/lib/hooks/useLocale";
8-
import { Button, Dialog, DialogContent, DialogFooter, Form, TextField } from "@calcom/ui";
8+
import { Button, Dialog, DialogContent, DialogFooter, Form, PasswordField, showToast } from "@calcom/ui";
99

1010
import TwoFactor from "@components/auth/TwoFactor";
1111

@@ -28,6 +28,7 @@ interface EnableTwoFactorModalProps {
2828

2929
enum SetupStep {
3030
ConfirmPassword,
31+
DisplayBackupCodes,
3132
DisplayQrCode,
3233
EnterTotpCode,
3334
}
@@ -54,16 +55,25 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
5455

5556
const setupDescriptions = {
5657
[SetupStep.ConfirmPassword]: t("2fa_confirm_current_password"),
58+
[SetupStep.DisplayBackupCodes]: t("backup_code_instructions"),
5759
[SetupStep.DisplayQrCode]: t("2fa_scan_image_or_use_code"),
5860
[SetupStep.EnterTotpCode]: t("2fa_enter_six_digit_code"),
5961
};
6062
const [step, setStep] = useState(SetupStep.ConfirmPassword);
6163
const [password, setPassword] = useState("");
64+
const [backupCodes, setBackupCodes] = useState([]);
65+
const [backupCodesUrl, setBackupCodesUrl] = useState("");
6266
const [dataUri, setDataUri] = useState("");
6367
const [secret, setSecret] = useState("");
6468
const [isSubmitting, setIsSubmitting] = useState(false);
6569
const [errorMessage, setErrorMessage] = useState<string | null>(null);
6670

71+
const resetState = () => {
72+
setPassword("");
73+
setErrorMessage(null);
74+
setStep(SetupStep.ConfirmPassword);
75+
};
76+
6777
async function handleSetup(e: React.FormEvent) {
6878
e.preventDefault();
6979

@@ -79,6 +89,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
7989
const body = await response.json();
8090

8191
if (response.status === 200) {
92+
setBackupCodes(body.backupCodes);
93+
94+
// create backup codes download url
95+
const textBlob = new Blob([body.backupCodes.map(formatBackupCode).join("\n")], {
96+
type: "text/plain",
97+
});
98+
if (backupCodesUrl) URL.revokeObjectURL(backupCodesUrl);
99+
setBackupCodesUrl(URL.createObjectURL(textBlob));
100+
82101
setDataUri(body.dataUri);
83102
setSecret(body.secret);
84103
setStep(SetupStep.DisplayQrCode);
@@ -113,7 +132,7 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
113132
const body = await response.json();
114133

115134
if (response.status === 200) {
116-
onEnable();
135+
setStep(SetupStep.DisplayBackupCodes);
117136
return;
118137
}
119138

@@ -141,13 +160,18 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
141160
}
142161
}, [form, handleEnableRef, totpCode]);
143162

163+
const formatBackupCode = (code: string) => `${code.slice(0, 5)}-${code.slice(5, 10)}`;
164+
144165
return (
145166
<Dialog open={open} onOpenChange={onOpenChange}>
146-
<DialogContent title={t("enable_2fa")} description={setupDescriptions[step]} type="creation">
167+
<DialogContent
168+
title={step === SetupStep.DisplayBackupCodes ? t("backup_codes") : t("enable_2fa")}
169+
description={setupDescriptions[step]}
170+
type="creation">
147171
<WithStep step={SetupStep.ConfirmPassword} current={step}>
148172
<form onSubmit={handleSetup}>
149173
<div className="mb-4">
150-
<TextField
174+
<PasswordField
151175
label={t("password")}
152176
type="password"
153177
name="password"
@@ -173,6 +197,15 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
173197
</p>
174198
</>
175199
</WithStep>
200+
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
201+
<>
202+
<div className="mt-5 grid grid-cols-2 gap-1 text-center font-mono md:pl-10 md:pr-10">
203+
{backupCodes.map((code) => (
204+
<div key={code}>{formatBackupCode(code)}</div>
205+
))}
206+
</div>
207+
</>
208+
</WithStep>
176209
<Form handleSubmit={handleEnable} form={form}>
177210
<WithStep step={SetupStep.EnterTotpCode} current={step}>
178211
<div className="-mt-4 pb-2">
@@ -186,9 +219,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
186219
</div>
187220
</WithStep>
188221
<DialogFooter className="mt-8" showDivider>
189-
<Button color="secondary" onClick={onCancel}>
190-
{t("cancel")}
191-
</Button>
222+
{step !== SetupStep.DisplayBackupCodes ? (
223+
<Button
224+
color="secondary"
225+
onClick={() => {
226+
onCancel();
227+
resetState();
228+
}}>
229+
{t("cancel")}
230+
</Button>
231+
) : null}
192232
<WithStep step={SetupStep.ConfirmPassword} current={step}>
193233
<Button
194234
type="submit"
@@ -218,6 +258,35 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
218258
{t("enable")}
219259
</Button>
220260
</WithStep>
261+
<WithStep step={SetupStep.DisplayBackupCodes} current={step}>
262+
<>
263+
<Button
264+
color="secondary"
265+
data-testid="backup-codes-close"
266+
onClick={(e) => {
267+
e.preventDefault();
268+
resetState();
269+
onEnable();
270+
}}>
271+
{t("close")}
272+
</Button>
273+
<Button
274+
color="secondary"
275+
data-testid="backup-codes-copy"
276+
onClick={(e) => {
277+
e.preventDefault();
278+
navigator.clipboard.writeText(backupCodes.map(formatBackupCode).join("\n"));
279+
showToast(t("backup_codes_copied"), "success");
280+
}}>
281+
{t("copy")}
282+
</Button>
283+
<a download="cal-backup-codes.txt" href={backupCodesUrl}>
284+
<Button color="primary" data-testid="backup-codes-download">
285+
{t("download")}
286+
</Button>
287+
</a>
288+
</>
289+
</WithStep>
221290
</DialogFooter>
222291
</Form>
223292
</DialogContent>

apps/web/components/settings/TwoFactorAuthAPI.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,10 +19,10 @@ const TwoFactorAuthAPI = {
1919
});
2020
},
2121

22-
async disable(password: string, code: string) {
22+
async disable(password: string, code: string, backupCode: string) {
2323
return fetch("/api/auth/two-factor/totp/disable", {
2424
method: "POST",
25-
body: JSON.stringify({ password, code }),
25+
body: JSON.stringify({ password, code, backupCode }),
2626
headers: {
2727
"Content-Type": "application/json",
2828
},

apps/web/pages/api/auth/two-factor/totp/disable.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,30 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
4343
return res.status(400).json({ error: ErrorCode.IncorrectPassword });
4444
}
4545
}
46-
// if user has 2fa
47-
if (user.twoFactorEnabled) {
46+
47+
// if user has 2fa and using backup code
48+
if (user.twoFactorEnabled && req.body.backupCode) {
49+
if (!process.env.CALENDSO_ENCRYPTION_KEY) {
50+
console.error("Missing encryption key; cannot proceed with backup code login.");
51+
throw new Error(ErrorCode.InternalServerError);
52+
}
53+
54+
if (!user.backupCodes) {
55+
return res.status(400).json({ error: ErrorCode.MissingBackupCodes });
56+
}
57+
58+
const backupCodes = JSON.parse(symmetricDecrypt(user.backupCodes, process.env.CALENDSO_ENCRYPTION_KEY));
59+
60+
// check if user-supplied code matches one
61+
const index = backupCodes.indexOf(req.body.backupCode.replaceAll("-", ""));
62+
if (index === -1) {
63+
return res.status(400).json({ error: ErrorCode.IncorrectBackupCode });
64+
}
65+
66+
// we delete all stored backup codes at the end, no need to do this here
67+
68+
// if user has 2fa and NOT using backup code, try totp
69+
} else if (user.twoFactorEnabled) {
4870
if (!req.body.code) {
4971
return res.status(400).json({ error: ErrorCode.SecondFactorRequired });
5072
// throw new Error(ErrorCode.SecondFactorRequired);
@@ -82,6 +104,7 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
82104
id: session.user.id,
83105
},
84106
data: {
107+
backupCodes: null,
85108
twoFactorEnabled: false,
86109
twoFactorSecret: null,
87110
},

apps/web/pages/api/auth/two-factor/totp/setup.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import crypto from "crypto";
12
import type { NextApiRequest, NextApiResponse } from "next";
23
import { authenticator } from "otplib";
34
import qrcode from "qrcode";
@@ -56,11 +57,15 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
5657
// bytes without updating the sanity checks in the enable and login endpoints.
5758
const secret = authenticator.generateSecret(20);
5859

60+
// generate backup codes with 10 character length
61+
const backupCodes = Array.from(Array(10), () => crypto.randomBytes(5).toString("hex"));
62+
5963
await prisma.user.update({
6064
where: {
6165
id: session.user.id,
6266
},
6367
data: {
68+
backupCodes: symmetricEncrypt(JSON.stringify(backupCodes), process.env.CALENDSO_ENCRYPTION_KEY),
6469
twoFactorEnabled: false,
6570
twoFactorSecret: symmetricEncrypt(secret, process.env.CALENDSO_ENCRYPTION_KEY),
6671
},
@@ -70,5 +75,5 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse)
7075
const keyUri = authenticator.keyuri(name, "Cal", secret);
7176
const dataUri = await qrcode.toDataURL(keyUri);
7277

73-
return res.json({ secret, keyUri, dataUri });
78+
return res.json({ secret, keyUri, dataUri, backupCodes });
7479
}

0 commit comments

Comments
 (0)