test: Two Factor Auth (#9142)

* Fix 2 Factor Auth

* Add 2fa tests
This commit is contained in:
Hariom Balhara
2023-05-30 14:23:49 +00:00
committed by GitHub
parent 6daa69cff9
commit fba2a2e2ca
5 changed files with 172 additions and 8 deletions
@@ -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");
+156
View File
@@ -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]);
}