fix: Password reset tests & better expiry checking (#10102)
parent
6d413f5721
commit
7b57b4bcda
|
@ -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" });
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"])),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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.");
|
||||
});
|
||||
|
|
Loading…
Reference in New Issue