@@ -178,12 +184,16 @@ const EnableTwoFactorModal = ({ onEnable, onCancel, open, onOpenChange }: Enable
-
-
+
{t("enable")}
diff --git a/apps/web/pages/settings/security/two-factor-auth.tsx b/apps/web/pages/settings/security/two-factor-auth.tsx
index 811f9d2d03..9c0e74dd46 100644
--- a/apps/web/pages/settings/security/two-factor-auth.tsx
+++ b/apps/web/pages/settings/security/two-factor-auth.tsx
@@ -40,6 +40,7 @@ const TwoFactorAuthView = () => {
{!isCalProvider &&
}
diff --git a/apps/web/playwright/auth/auth-index.e2e.ts b/apps/web/playwright/auth/auth-index.e2e.ts
index fac393621f..19c568bb14 100644
--- a/apps/web/playwright/auth/auth-index.e2e.ts
+++ b/apps/web/playwright/auth/auth-index.e2e.ts
@@ -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");
diff --git a/apps/web/playwright/login.2fa.e2e.ts b/apps/web/playwright/login.2fa.e2e.ts
new file mode 100644
index 0000000000..2a869e5763
--- /dev/null
+++ b/apps/web/playwright/login.2fa.e2e.ts
@@ -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]);
+}