2023-05-30 14:23:49 +00:00
|
|
|
import type { Page } from "@playwright/test";
|
|
|
|
import { expect } from "@playwright/test";
|
|
|
|
import { authenticator } from "otplib";
|
|
|
|
|
|
|
|
import { symmetricDecrypt } from "@calcom/lib/crypto";
|
2023-08-17 14:13:04 +00:00
|
|
|
import { totpAuthenticatorCheck } from "@calcom/lib/totp";
|
2023-09-14 19:47:25 +00:00
|
|
|
import { prisma } from "@calcom/prisma";
|
2023-05-30 14:23:49 +00:00
|
|
|
|
|
|
|
import { test } from "./lib/fixtures";
|
|
|
|
|
|
|
|
test.describe.configure({ mode: "parallel" });
|
|
|
|
|
2023-08-30 07:33:48 +00:00
|
|
|
// TODO: add more backup code tests, e.g. login + disabling 2fa with backup
|
|
|
|
|
2023-05-30 14:23:49 +00:00
|
|
|
// 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 expect(page.locator('[data-testid="error-submitting-code"]')).toBeVisible();
|
|
|
|
|
|
|
|
await fillOtp({
|
|
|
|
page,
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
|
|
|
secret: secret!,
|
|
|
|
});
|
|
|
|
|
2023-08-30 07:33:48 +00:00
|
|
|
// FIXME: this passes even when switch is not checked, compare to test
|
|
|
|
// below which checks for data-state="checked" and works as expected
|
2023-07-20 19:40:35 +00:00
|
|
|
await page.waitForSelector(`[data-testid=two-factor-switch]`);
|
|
|
|
await expect(page.locator(`[data-testid=two-factor-switch]`).isChecked()).toBeTruthy();
|
2023-05-30 14:23:49 +00:00
|
|
|
|
|
|
|
return user;
|
|
|
|
});
|
|
|
|
|
|
|
|
await test.step("Logout", async () => {
|
|
|
|
await page.goto("/auth/logout");
|
|
|
|
});
|
|
|
|
|
|
|
|
await test.step("Login with 2FA enabled", async () => {
|
|
|
|
await user.login();
|
2023-09-14 19:47:25 +00:00
|
|
|
const userWith2FaSecret = await prisma.user.findFirst({
|
2023-05-30 14:23:49 +00:00
|
|
|
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! });
|
2023-07-13 20:54:57 +00:00
|
|
|
|
2023-08-30 07:33:48 +00:00
|
|
|
// backup codes are now showing, so run a few tests
|
|
|
|
|
|
|
|
// click download button
|
|
|
|
const promise = page.waitForEvent("download");
|
|
|
|
await page.getByTestId("backup-codes-download").click();
|
|
|
|
const download = await promise;
|
|
|
|
expect(download.suggestedFilename()).toBe("cal-backup-codes.txt");
|
|
|
|
// TODO: check file content
|
|
|
|
|
|
|
|
// click copy button
|
|
|
|
await page.getByTestId("backup-codes-copy").click();
|
|
|
|
await page.getByTestId("toast-success").waitFor();
|
|
|
|
// TODO: check clipboard content
|
|
|
|
|
|
|
|
// close backup code dialog
|
|
|
|
await page.getByTestId("backup-codes-close").click();
|
|
|
|
|
2023-05-30 14:23:49 +00:00
|
|
|
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);
|
|
|
|
|
2023-09-14 19:47:25 +00:00
|
|
|
const userWith2FaSecret = await prisma.user.findFirst({
|
2023-05-30 14:23:49 +00:00
|
|
|
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);
|
2023-08-17 14:13:04 +00:00
|
|
|
if (!noRetry && !totpAuthenticatorCheck(token, secret)) {
|
2023-05-30 14:23:49 +00:00
|
|
|
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]);
|
|
|
|
}
|