diff --git a/apps/web/pages/api/auth/forgot-password.ts b/apps/web/pages/api/auth/forgot-password.ts index 5849539c58..58cb92ef4e 100644 --- a/apps/web/pages/api/auth/forgot-password.ts +++ b/apps/web/pages/api/auth/forgot-password.ts @@ -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" }); diff --git a/apps/web/pages/api/auth/reset-password.ts b/apps/web/pages/api/auth/reset-password.ts index 25dd1a2a35..57466aa203 100644 --- a/apps/web/pages/api/auth/reset-password.ts +++ b/apps/web/pages/api/auth/reset-password.ts @@ -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) { diff --git a/apps/web/pages/auth/forgot-password/[id].tsx b/apps/web/pages/auth/forgot-password/[id].tsx index b76d52efeb..c7e88f4978 100644 --- a/apps/web/pages/auth/forgot-password/[id].tsx +++ b/apps/web/pages/auth/forgot-password/[id].tsx @@ -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 ( { await submitChangePassword({ password: values.new_password, - requestId: resetPasswordRequest.id, + requestId, }); }}> @@ -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"])), + }, + }; } diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index ca87930187..0524a538e0 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -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."); });