fix: Password reset tests & better expiry checking (#10102)
parent
6d413f5721
commit
7b57b4bcda
|
@ -88,17 +88,9 @@ async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
resetLink,
|
resetLink,
|
||||||
});
|
});
|
||||||
|
|
||||||
/** So we can test the password reset flow on CI */
|
return res
|
||||||
if (process.env.NEXT_PUBLIC_IS_E2E) {
|
.status(201)
|
||||||
return res.status(201).json({
|
.json({ message: "If this email exists in our system, you should receive a Reset email." });
|
||||||
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." });
|
|
||||||
}
|
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
// console.error(reason);
|
// console.error(reason);
|
||||||
return res.status(500).json({ message: "Unable to create password reset request" });
|
return res.status(500).json({ message: "Unable to create password reset request" });
|
||||||
|
|
|
@ -1,64 +1,56 @@
|
||||||
import type { NextApiRequest, NextApiResponse } from "next";
|
import type { NextApiRequest, NextApiResponse } from "next";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
import { hashPassword } from "@calcom/features/auth/lib/hashPassword";
|
||||||
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
import { validPassword } from "@calcom/features/auth/lib/validPassword";
|
||||||
import prisma from "@calcom/prisma";
|
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) {
|
export default async function handler(req: NextApiRequest, res: NextApiResponse) {
|
||||||
if (req.method !== "POST") {
|
// Bad Method when not POST
|
||||||
return res.status(400).json({ message: "" });
|
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 {
|
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({
|
await prisma.user.update({
|
||||||
where: {
|
where: {
|
||||||
id: maybeUser.id,
|
email: maybeRequest.email,
|
||||||
},
|
},
|
||||||
data: {
|
data: {
|
||||||
password: hashedPassword,
|
password: hashedPassword,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
} catch (e) {
|
||||||
await expireResetPasswordRequest(rawRequestId);
|
return res.status(404).end();
|
||||||
|
|
||||||
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" });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await expireResetPasswordRequest(rawRequestId);
|
||||||
|
|
||||||
|
return res.status(201).json({ message: "Password reset." });
|
||||||
}
|
}
|
||||||
|
|
||||||
async function expireResetPasswordRequest(rawRequestId: string) {
|
async function expireResetPasswordRequest(rawRequestId: string) {
|
||||||
|
|
|
@ -1,13 +1,11 @@
|
||||||
import type { ResetPasswordRequest } from "@prisma/client";
|
|
||||||
import type { GetServerSidePropsContext } from "next";
|
import type { GetServerSidePropsContext } from "next";
|
||||||
import { getCsrfToken } from "next-auth/react";
|
import { getCsrfToken } from "next-auth/react";
|
||||||
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import type { CSSProperties } from "react";
|
import type { CSSProperties } from "react";
|
||||||
import React, { useMemo } from "react";
|
import React from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
|
|
||||||
import dayjs from "@calcom/dayjs";
|
|
||||||
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
import { useLocale } from "@calcom/lib/hooks/useLocale";
|
||||||
import prisma from "@calcom/prisma";
|
import prisma from "@calcom/prisma";
|
||||||
import { Button, PasswordField, Form } from "@calcom/ui";
|
import { Button, PasswordField, Form } from "@calcom/ui";
|
||||||
|
@ -16,12 +14,12 @@ import PageWrapper from "@components/PageWrapper";
|
||||||
import AuthContainer from "@components/ui/AuthContainer";
|
import AuthContainer from "@components/ui/AuthContainer";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
id: string;
|
requestId: string;
|
||||||
resetPasswordRequest: ResetPasswordRequest;
|
isRequestExpired: boolean;
|
||||||
csrfToken: string;
|
csrfToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
export default function Page({ requestId, isRequestExpired, csrfToken }: Props) {
|
||||||
const { t } = useLocale();
|
const { t } = useLocale();
|
||||||
const formMethods = useForm<{ new_password: string }>();
|
const formMethods = useForm<{ new_password: string }>();
|
||||||
const success = formMethods.formState.isSubmitSuccessful;
|
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 (
|
return (
|
||||||
<AuthContainer
|
<AuthContainer
|
||||||
showLogo
|
showLogo
|
||||||
|
@ -105,7 +98,7 @@ export default function Page({ resetPasswordRequest, csrfToken }: Props) {
|
||||||
handleSubmit={async (values) => {
|
handleSubmit={async (values) => {
|
||||||
await submitChangePassword({
|
await submitChangePassword({
|
||||||
password: values.new_password,
|
password: values.new_password,
|
||||||
requestId: resetPasswordRequest.id,
|
requestId,
|
||||||
});
|
});
|
||||||
}}>
|
}}>
|
||||||
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
<input name="csrfToken" type="hidden" defaultValue={csrfToken} hidden />
|
||||||
|
@ -152,31 +145,29 @@ Page.PageWrapper = PageWrapper;
|
||||||
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
export async function getServerSideProps(context: GetServerSidePropsContext) {
|
||||||
const id = context.params?.id as string;
|
const id = context.params?.id as string;
|
||||||
|
|
||||||
|
let resetPasswordRequest = await prisma.resetPasswordRequest.findFirst({
|
||||||
|
where: {
|
||||||
|
id,
|
||||||
|
expires: {
|
||||||
|
gt: new Date(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
email: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const resetPasswordRequest = await prisma.resetPasswordRequest.findUniqueOrThrow({
|
resetPasswordRequest &&
|
||||||
where: {
|
(await prisma.user.findUniqueOrThrow({ where: { email: resetPasswordRequest.email } }));
|
||||||
id,
|
} catch (e) {
|
||||||
},
|
resetPasswordRequest = null;
|
||||||
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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
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 { 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";
|
import { test } from "../lib/fixtures";
|
||||||
|
|
||||||
test.afterEach(({ users }) => users.deleteAll());
|
test.afterEach(({ users }) => users.deleteAll());
|
||||||
|
|
||||||
test("Can reset forgotten password", async ({ page, users }) => {
|
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 user = await users.create();
|
||||||
const newPassword = `${user.username}-123CAL`; // To match the password policy
|
|
||||||
// Got to reset password flow
|
// Got to reset password flow
|
||||||
await page.goto("/auth/forgot-password");
|
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.fill('input[name="email"]', `${user.username}@example.com`);
|
||||||
|
await page.press('input[name="email"]', "Enter");
|
||||||
|
|
||||||
// Press Enter
|
// wait for confirm page.
|
||||||
await Promise.all([
|
await page.waitForSelector("text=Reset link sent");
|
||||||
page.waitForURL((u) => u.pathname.startsWith("/auth/forgot-password")),
|
|
||||||
page.press('input[name="email"]', "Enter"),
|
// 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
|
// Wait for page to fully load
|
||||||
await page.waitForSelector("text=Reset Password");
|
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.click('button[type="submit"]');
|
||||||
|
|
||||||
await page.waitForSelector("text=Password updated");
|
await page.waitForSelector("text=Password updated");
|
||||||
|
|
||||||
await expect(page.locator(`text=Password updated`)).toBeVisible();
|
await expect(page.locator(`text=Password updated`)).toBeVisible();
|
||||||
// Click button:has-text("Login")
|
// now we check our DB to confirm the password was indeed updated.
|
||||||
await Promise.all([
|
// we're not logging in to the UI to speed up test performance.
|
||||||
page.waitForURL((u) => u.pathname.startsWith("/auth/login")),
|
const updatedUser = await prisma.user.findUniqueOrThrow({
|
||||||
page.click('a:has-text("Login")'),
|
where: {
|
||||||
]);
|
email: `${user.username}@example.com`,
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
password: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// Fill input[name="email"]
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
await page.fill('input[name="email"]', `${user.username}@example.com`);
|
await expect(await verifyPassword(newPassword, updatedUser.password!)).toBeTruthy();
|
||||||
await page.fill('input[name="password"]', newPassword);
|
|
||||||
await page.press('input[name="password"]', "Enter");
|
|
||||||
await page.waitForSelector("[data-testid=dashboard-shell]");
|
|
||||||
|
|
||||||
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