@@ -90,7 +90,7 @@ const DisableTwoFactorAuthModal = ({
|
||||
</div>
|
||||
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
<Button type="submit" className="ms-2 me-2" disabled={isDisabling}>
|
||||
<Button type="submit" className="ms-2 me-2" data-testid="disable-2fa" disabled={isDisabling}>
|
||||
{t("disable")}
|
||||
</Button>
|
||||
<Button color="secondary" onClick={onCancel}>
|
||||
|
||||
@@ -156,7 +156,9 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<img src={dataUri} alt="" />
|
||||
}
|
||||
</div>
|
||||
<p className="text-center font-mono text-xs">{secret}</p>
|
||||
<p data-testid="two-factor-secret" className="text-center font-mono text-xs">
|
||||
{secret}
|
||||
</p>
|
||||
</>
|
||||
</WithStep>
|
||||
<Form handleSubmit={handleEnable} form={form}>
|
||||
@@ -164,7 +166,11 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
<div className="mb-4">
|
||||
<TwoFactor center />
|
||||
|
||||
{errorMessage && <p className="mt-1 text-sm text-red-700">{errorMessage}</p>}
|
||||
{errorMessage && (
|
||||
<p data-testid="error-submitting-code" className="mt-1 text-sm text-red-700">
|
||||
{errorMessage}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</WithStep>
|
||||
<div className="mt-5 sm:mt-4 sm:flex sm:flex-row-reverse">
|
||||
@@ -178,12 +184,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.DisplayQrCode} current={step}>
|
||||
<Button type="submit" className="ms-2 me-2" onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
<Button
|
||||
type="submit"
|
||||
data-testid="goto-otp-screen"
|
||||
className="ms-2 me-2"
|
||||
onClick={() => setStep(SetupStep.EnterTotpCode)}>
|
||||
{t("continue")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
<WithStep step={SetupStep.EnterTotpCode} current={step}>
|
||||
<Button type="submit" className="ms-2 me-2" disabled={isSubmitting}>
|
||||
<Button type="submit" className="ms-2 me-2" data-testid="enable-2fa" disabled={isSubmitting}>
|
||||
{t("enable")}
|
||||
</Button>
|
||||
</WithStep>
|
||||
|
||||
@@ -40,6 +40,7 @@ const TwoFactorAuthView = () => {
|
||||
{!isCalProvider && <Alert severity="neutral" message={t("2fa_disabled")} />}
|
||||
<div className="mt-6 flex items-start space-x-4">
|
||||
<Switch
|
||||
data-testid="two-factor-switch"
|
||||
disabled={!isCalProvider}
|
||||
checked={user?.twoFactorEnabled}
|
||||
onCheckedChange={() =>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { test } from "../lib/fixtures";
|
||||
import { todo } from "../lib/testUtils";
|
||||
|
||||
test.describe("Can signup from a team invite", async () => {
|
||||
test.beforeEach(async ({ users }) => {
|
||||
@@ -96,5 +95,3 @@ test.describe("Can signup from a team invite", async () => {
|
||||
expect(createdUser.teams[0].accepted).toBe(true);*/
|
||||
});
|
||||
});
|
||||
|
||||
todo("Can login using 2FA");
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { Page } from "@playwright/test";
|
||||
import { expect } from "@playwright/test";
|
||||
import { authenticator } from "otplib";
|
||||
|
||||
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
||||
|
||||
import { test } from "./lib/fixtures";
|
||||
|
||||
test.describe.configure({ mode: "parallel" });
|
||||
|
||||
// a test to logout requires both a succesfull login as logout, to prevent
|
||||
// a doubling of tests failing on logout & logout, we can group them.
|
||||
test.describe("2FA Tests", async () => {
|
||||
test.afterAll(async ({ users }) => {
|
||||
await users.deleteAll();
|
||||
});
|
||||
test("should allow a user to enable 2FA and login using 2FA", async ({ page, users }) => {
|
||||
// log in trail user
|
||||
const user = await test.step("Enable 2FA", async () => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const userPassword = user.username!;
|
||||
await user.login();
|
||||
|
||||
// expects the home page for an authorized user
|
||||
await page.goto("/settings/security/two-factor-auth");
|
||||
await page.click(`[data-testid=two-factor-switch]`);
|
||||
await page.fill('input[name="password"]', userPassword);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
const secret = await page.locator(`[data-testid=two-factor-secret]`).textContent();
|
||||
expect(secret).toHaveLength(32);
|
||||
await page.click('[data-testid="goto-otp-screen"]');
|
||||
|
||||
/**
|
||||
* Try a wrong code and test that wrong code is rejected.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: "123456", noRetry: true });
|
||||
await page.press('input[name="2fa6"]', "Enter");
|
||||
await expect(page.locator('[data-testid="error-submitting-code"]')).toBeVisible();
|
||||
|
||||
await fillOtp({
|
||||
page,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
secret: secret!,
|
||||
});
|
||||
await page.press('input[name="2fa6"]', "Enter");
|
||||
|
||||
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
await test.step("Logout", async () => {
|
||||
await page.goto("/auth/logout");
|
||||
});
|
||||
|
||||
await test.step("Login with 2FA enabled", async () => {
|
||||
await user.login();
|
||||
const userWith2FaSecret = await prisma?.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const secret = symmetricDecrypt(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
userWith2FaSecret!.twoFactorSecret!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
process.env.CALENDSO_ENCRYPTION_KEY!
|
||||
);
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
await Promise.all([
|
||||
page.press('input[name="2fa6"]', "Enter"),
|
||||
page.waitForResponse("**/api/auth/callback/credentials**"),
|
||||
]);
|
||||
const shellLocator = page.locator(`[data-testid=dashboard-shell]`);
|
||||
|
||||
// expects the home page for an authorized user
|
||||
await page.goto("/");
|
||||
await expect(shellLocator).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test("should allow a user to disable 2FA", async ({ page, users }) => {
|
||||
// log in trail user
|
||||
const user = await test.step("Enable 2FA", async () => {
|
||||
const user = await users.create();
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const userPassword = user.username!;
|
||||
await user.login();
|
||||
|
||||
// expects the home page for an authorized user
|
||||
await page.goto("/settings/security/two-factor-auth");
|
||||
await page.click(`[data-testid=two-factor-switch][data-state="unchecked"]`);
|
||||
await page.fill('input[name="password"]', userPassword);
|
||||
await page.press('input[name="password"]', "Enter");
|
||||
const secret = await page.locator(`[data-testid=two-factor-secret]`).textContent();
|
||||
expect(secret).toHaveLength(32);
|
||||
await page.click('[data-testid="goto-otp-screen"]');
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
await page.click('[data-testid="enable-2fa"]');
|
||||
await expect(page.locator(`[data-testid=two-factor-switch][data-state="checked"]`)).toBeVisible();
|
||||
|
||||
return user;
|
||||
});
|
||||
|
||||
await test.step("Disable 2FA", async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
const userPassword = user.username!;
|
||||
|
||||
// expects the home page for an authorized user
|
||||
await page.goto("/settings/security/two-factor-auth");
|
||||
await page.click(`[data-testid=two-factor-switch][data-state="checked"]`);
|
||||
await page.fill('input[name="password"]', userPassword);
|
||||
|
||||
const userWith2FaSecret = await prisma?.user.findFirst({
|
||||
where: {
|
||||
id: user.id,
|
||||
},
|
||||
});
|
||||
|
||||
const secret = symmetricDecrypt(
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
userWith2FaSecret!.twoFactorSecret!,
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
process.env.CALENDSO_ENCRYPTION_KEY!
|
||||
);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||
await fillOtp({ page, secret: secret! });
|
||||
await page.click('[data-testid="disable-2fa"]');
|
||||
await expect(page.locator(`[data-testid=two-factor-switch][data-state="unchecked"]`)).toBeVisible();
|
||||
|
||||
return user;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
async function fillOtp({ page, secret, noRetry }: { page: Page; secret: string; noRetry?: boolean }) {
|
||||
let token = authenticator.generate(secret);
|
||||
if (!noRetry && !authenticator.check(token, secret)) {
|
||||
console.log("Token expired, Renerating.");
|
||||
// Maybe token was just about to expire, try again just once more
|
||||
token = authenticator.generate(secret);
|
||||
}
|
||||
await page.fill('input[name="2fa1"]', token[0]);
|
||||
await page.fill('input[name="2fa2"]', token[1]);
|
||||
await page.fill('input[name="2fa3"]', token[2]);
|
||||
await page.fill('input[name="2fa4"]', token[3]);
|
||||
await page.fill('input[name="2fa5"]', token[4]);
|
||||
await page.fill('input[name="2fa6"]', token[5]);
|
||||
}
|
||||
Reference in New Issue
Block a user