fix: Password reset tests & better expiry checking (#10102)

pull/10104/head
Alex van Andel 2023-07-12 19:27:41 +02:00 committed by GitHub
parent 6d413f5721
commit 7b57b4bcda
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 135 additions and 116 deletions

View File

@ -88,17 +88,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
resetLink,
});
/** So we can test the password reset flow on CI */
if (process.env.NEXT_PUBLIC_IS_E2E) {
return res.status(201).json({
message: "If this email exists in our system, you should receive a Reset email.",
resetLink,
});
} else {
return res
.status(201)
.json({ message: "If this email exists in our system, you should receive a Reset email." });
}
return res
.status(201)
.json({ message: "If this email exists in our system, you should receive a Reset email." });
} catch (reason) {
// console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });

View File

@ -1,64 +1,56 @@
import type { NextApiRequest, NextApiResponse } from "next";
import { z } from "zod";
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
import { validPassword } from "@calcom/features/auth/lib/validPassword";
import prisma from "@calcom/prisma";
const passwordResetRequestSchema = z.object({
password: z.string().refine(validPassword, () => ({
message: "Password does not meet the requirements",
})),
requestId: z.string(), // format doesn't matter.
});
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
if (req.method !== "POST") {
return res.status(400).json({ message: "" });
}
// Bad Method when not POST
if (req.method !== "POST") return res.status(405).end();
const { password: rawPassword, requestId: rawRequestId } = passwordResetRequestSchema.parse(req.body);
// rate-limited there is a low, very low chance that a password request stays valid long enough
// to brute force 3.8126967e+40 options.
const maybeRequest = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
id: rawRequestId,
expires: {
gt: new Date(),
},
},
select: {
email: true,
},
});
const hashedPassword = await hashPassword(rawPassword);
// this can fail if a password request has been made for an email that has since changed or-
// never existed within Cal. In this case we do not want to disclose the email's existence.
// instead, we just return 404
try {
const rawPassword = req.body?.password;
const rawRequestId = req.body?.requestId;
if (!rawPassword || !rawRequestId) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const maybeRequest = await prisma.resetPasswordRequest.findUnique({
where: {
id: rawRequestId,
},
});
if (!maybeRequest) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
const maybeUser = await prisma.user.findUnique({
where: {
email: maybeRequest.email,
},
});
if (!maybeUser) {
return res.status(400).json({ message: "Couldn't find an account for this email" });
}
if (!validPassword(rawPassword)) {
return res.status(400).json({ message: "Password does not meet the requirements" });
}
const hashedPassword = await hashPassword(rawPassword);
await prisma.user.update({
where: {
id: maybeUser.id,
email: maybeRequest.email,
},
data: {
password: hashedPassword,
},
});
await expireResetPasswordRequest(rawRequestId);
return res.status(201).json({ message: "Password reset." });
} catch (reason) {
console.error(reason);
return res.status(500).json({ message: "Unable to create password reset request" });
} catch (e) {
return res.status(404).end();
}
await expireResetPasswordRequest(rawRequestId);
return res.status(201).json({ message: "Password reset." });
}
async function expireResetPasswordRequest(rawRequestId: string) {

View File

@ -1,13 +1,11 @@
import type { ResetPasswordRequest } from "@prisma/client";
import type { GetServerSidePropsContext } from "next";
import { getCsrfToken } from "next-auth/react";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import Link from "next/link";
import type { CSSProperties } from "react";
import React, { useMemo } from "react";
import React from "react";
import { useForm } from "react-hook-form";
import dayjs from "@calcom/dayjs";
import { useLocale } from "@calcom/lib/hooks/useLocale";
import prisma from "@calcom/prisma";
import { Button, PasswordField, Form } from "@calcom/ui";
@ -16,12 +14,12 @@ import PageWrapper from "@components/PageWrapper";
import AuthContainer from "@components/ui/AuthContainer";
type Props = {
id: string;
resetPasswordRequest: ResetPasswordRequest;
requestId: string;
isRequestExpired: boolean;
csrfToken: string;
};
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
export default function Page({ requestId, isRequestExpired, csrfToken }: Props) {
const { t } = useLocale();
const formMethods = useForm<{ new_password: string }>();
const success = formMethods.formState.isSubmitSuccessful;
@ -77,11 +75,6 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
);
};
const isRequestExpired = useMemo(() => {
const now = dayjs();
return dayjs(resetPasswordRequest.expires).isBefore(now);
}, [resetPasswordRequest]);
return (
<AuthContainer
showLogo
@ -105,7 +98,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
handleSubmit={async (values) => {
await submitChangePassword({
password: values.new_password,
requestId: resetPasswordRequest.id,
requestId,
});
}}>
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
@ -152,31 +145,29 @@ Page.PageWrapper = PageWrapper;
export async function getServerSideProps(context: GetServerSidePropsContext) {
const id = context.params?.id as string;
let resetPasswordRequest = await prisma.resetPasswordRequest.findFirst({
where: {
id,
expires: {
gt: new Date(),
},
},
select: {
email: true,
},
});
try {
const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({
where: {
id,
},
select: {
id: true,
expires: true,
},
});
return {
props: {
resetPasswordRequest: {
...resetPasswordRequest,
expires: resetPasswordRequest.expires.toString(),
},
id,
csrfToken: await getCsrfToken({ req: context.req }),
...(await serverSideTranslations(context.locale || "en", ["common"])),
},
};
} catch (reason) {
return {
notFound: true,
};
resetPasswordRequest &&
(await prisma.user.findUniqueOrThrow({ where: { email: resetPasswordRequest.email } }));
} catch (e) {
resetPasswordRequest = null;
}
return {
props: {
isRequestExpired: !resetPasswordRequest,
requestId: id,
csrfToken: await getCsrfToken({ req: context.req }),
...(await serverSideTranslations(context.locale || "en", ["common"])),
},
};
}

View File

@ -1,49 +1,93 @@
import { expect } from "@playwright/test";
import { uuid } from "short-uuid";
import { verifyPassword } from "@calcom/features/auth/lib/verifyPassword";
import prisma from "@calcom/prisma";
import { test } from "../lib/fixtures";
test.afterEach(({ users }) => users.deleteAll());
test("Can reset forgotten password", async ({ page, users }) => {
// eslint-disable-next-line playwright/no-skipped-test
test.skip(process.env.NEXT_PUBLIC_IS_E2E !== "1", "It shouldn't if we can't skip email");
const user = await users.create();
const newPassword = `${user.username}-123CAL`; // To match the password policy
// Got to reset password flow
await page.goto("/auth/forgot-password");
await page.waitForSelector("text=Forgot Password");
// Fill [placeholder="john.doe@example.com"]
await page.fill('input[name="email"]', `${user.username}@example.com`);
await page.press('input[name="email"]', "Enter");
// Press Enter
await Promise.all([
page.waitForURL((u) => u.pathname.startsWith("/auth/forgot-password")),
page.press('input[name="email"]', "Enter"),
]);
// wait for confirm page.
await page.waitForSelector("text=Reset link sent");
// As a workaround, we query the db for the last created password request
// there should be one, otherwise we throw
const { id } = await prisma.resetPasswordRequest.findFirstOrThrow({
where: {
email: `${user.username}@example.com`,
},
select: {
id: true,
},
orderBy: {
createdAt: "desc",
},
});
// Test when a user changes his email after starting the password reset flow
await prisma.user.update({
where: {
email: `${user.username}@example.com`,
},
data: {
email: `${user.username}-2@example.com`,
},
});
await page.goto(`/auth/forgot-password/${id}`);
await page.waitForSelector("text=That request is expired.");
// Change the email back to continue testing.
await prisma.user.update({
where: {
email: `${user.username}-2@example.com`,
},
data: {
email: `${user.username}@example.com`,
},
});
await page.goto(`/auth/forgot-password/${id}`);
const newPassword = `${user.username}-123CAL-${uuid().toString()}`; // To match the password policy
// Wait for page to fully load
await page.waitForSelector("text=Reset Password");
// Fill input[name="new_password"]
await page.fill('input[name="new_password"]', newPassword);
// Click text=Submit
await page.fill('input[name="new_password"]', newPassword);
await page.click('button[type="submit"]');
await page.waitForSelector("text=Password updated");
await expect(page.locator(`text=Password updated`)).toBeVisible();
// Click button:has-text("Login")
await Promise.all([
page.waitForURL((u) => u.pathname.startsWith("/auth/login")),
page.click('a:has-text("Login")'),
]);
// now we check our DB to confirm the password was indeed updated.
// we're not logging in to the UI to speed up test performance.
const updatedUser = await prisma.user.findUniqueOrThrow({
where: {
email: `${user.username}@example.com`,
},
select: {
id: true,
password: true,
},
});
// Fill input[name="email"]
await page.fill('input[name="email"]', `${user.username}@example.com`);
await page.fill('input[name="password"]', newPassword);
await page.press('input[name="password"]', "Enter");
await page.waitForSelector("[data-testid=dashboard-shell]");
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
await expect(page.locator("[data-testid=dashboard-shell]")).toBeVisible();
// finally, make sure the same URL cannot be used to reset the password again, as it should be expired.
await page.goto(`/auth/forgot-password/${id}`);
await page.waitForSelector("text=That request is expired.");
});