diff --git a/apps/web/playwright/auth/forgot-password.e2e.ts b/apps/web/playwright/auth/forgot-password.e2e.ts index 1b62be7de9..3a53ede82e 100644 --- a/apps/web/playwright/auth/forgot-password.e2e.ts +++ b/apps/web/playwright/auth/forgot-password.e2e.ts @@ -88,7 +88,6 @@ test.describe("Forgot password", async () => { }, }); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const updatedPassword = updatedUser.password!.hash; expect(await verifyPassword(newPassword, updatedPassword)).toBeTruthy(); @@ -97,4 +96,70 @@ test.describe("Forgot password", async () => { await expect(page.locator(`text=Whoops`)).toBeVisible(); }); + + test("Old tokens are invalidated when new reset link is requested", async ({ page, users }) => { + const user = await users.create(); + + await page.goto("/auth/forgot-password"); + await page.waitForSelector("text=Forgot Password?"); + + await page.fill('input[name="email"]', `${user.username}@example.com`); + await page.press('input[name="email"]', "Enter"); + await page.waitForLoadState("networkidle"); + + await page.waitForSelector("text=Reset link sent"); + + const firstRequest = await prisma.resetPasswordRequest.findFirstOrThrow({ + where: { + email: user.email, + }, + select: { + id: true, + expires: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + await page.goto("/auth/forgot-password"); + await page.waitForSelector("text=Forgot Password?"); + + await page.fill('input[name="email"]', `${user.username}@example.com`); + await page.press('input[name="email"]', "Enter"); + await page.waitForLoadState("networkidle"); + + await page.waitForSelector("text=Reset link sent"); + + const secondRequest = await prisma.resetPasswordRequest.findFirstOrThrow({ + where: { + email: user.email, + }, + select: { + id: true, + expires: true, + }, + orderBy: { + createdAt: "desc", + }, + }); + + const firstRequestAfterSecond = await prisma.resetPasswordRequest.findUniqueOrThrow({ + where: { + id: firstRequest.id, + }, + select: { + expires: true, + }, + }); + + expect(firstRequest.id).not.toBe(secondRequest.id); + expect(firstRequestAfterSecond.expires.getTime()).toBeLessThanOrEqual(new Date().getTime()); + + await page.goto(`/auth/forgot-password/${firstRequest.id}`); + await expect(page.locator(`text=Whoops`)).toBeVisible(); + + await page.goto(`/auth/forgot-password/${secondRequest.id}`); + await expect(page.getByRole("heading", { name: "Reset Password" })).toBeVisible(); + }); }); diff --git a/packages/features/auth/lib/passwordResetRequest.ts b/packages/features/auth/lib/passwordResetRequest.ts index 6b6fbbea49..5e502d6ef9 100644 --- a/packages/features/auth/lib/passwordResetRequest.ts +++ b/packages/features/auth/lib/passwordResetRequest.ts @@ -10,6 +10,19 @@ const RECENT_PERIOD_IN_MINUTES = 5; const createPasswordReset = async (email: string): Promise => { const expiry = dayjs().add(PASSWORD_RESET_EXPIRY_HOURS, "hours").toDate(); + + await prisma.resetPasswordRequest.updateMany({ + where: { + email, + expires: { + gt: new Date(), + }, + }, + data: { + expires: new Date(), + }, + }); + const createdResetPasswordRequest = await prisma.resetPasswordRequest.create({ data: { email,