diff --git a/packages/app-tests/e2e/fixtures/authentication.ts b/packages/app-tests/e2e/fixtures/authentication.ts index 6f8fdbdf8..42245c3e0 100644 --- a/packages/app-tests/e2e/fixtures/authentication.ts +++ b/packages/app-tests/e2e/fixtures/authentication.ts @@ -1,4 +1,4 @@ -import { type Page } from '@playwright/test'; +import type { Page } from '@playwright/test'; import { NEXT_PUBLIC_WEBAPP_URL } from '@documenso/lib/constants/app'; @@ -58,3 +58,15 @@ const getCsrfToken = async (page: Page) => { return csrfToken; }; + +export const checkSessionValid = async (page: Page): Promise => { + const { request } = page.context(); + + const response = await request.fetch(`${NEXT_PUBLIC_WEBAPP_URL()}/api/auth/session`, { + method: 'get', + }); + + const session = await response.json(); + + return session.isAuthenticated === true; +}; diff --git a/packages/app-tests/e2e/user/password.spec.ts b/packages/app-tests/e2e/user/password.spec.ts index 8325e92fe..73d948831 100644 --- a/packages/app-tests/e2e/user/password.spec.ts +++ b/packages/app-tests/e2e/user/password.spec.ts @@ -3,7 +3,7 @@ import { type Page, expect, test } from '@playwright/test'; import { prisma } from '@documenso/prisma'; import { seedUser } from '@documenso/prisma/seed/users'; -import { apiSignin, apiSignout } from '../fixtures/authentication'; +import { apiSignin, apiSignout, checkSessionValid } from '../fixtures/authentication'; test.use({ storageState: { cookies: [], origins: [] } }); @@ -17,6 +17,7 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P await page.goto('http://localhost:3000/signin'); await page.getByRole('link', { name: 'Forgot your password?' }).click(); + await expect(page).toHaveURL('http://localhost:3000/forgot-password'); await page.getByRole('textbox', { name: 'Email' }).click(); await page.getByRole('textbox', { name: 'Email' }).fill(user.email); @@ -24,7 +25,9 @@ test('[USER] can reset password via forgot password', async ({ page }: { page: P await expect(page.getByRole('button', { name: 'Reset Password' })).toBeEnabled(); await page.getByRole('button', { name: 'Reset Password' }).click(); - await expect(page.locator('body')).toContainText('Reset email sent', { timeout: 10000 }); + await expect(page.locator('body')).toContainText('Reset email sent', { + timeout: 10000, + }); const foundToken = await prisma.passwordResetToken.findFirstOrThrow({ where: { @@ -109,3 +112,105 @@ test('[USER] can reset password via user settings', async ({ page }: { page: Pag await page.waitForURL('/settings/profile'); await expect(page).toHaveURL('/settings/profile'); }); + +test('[USER] password reset invalidates all sessions', async ({ page }: { page: Page }) => { + const oldPassword = 'Test123!'; + const newPassword = 'Test124!'; + + const { user } = await seedUser({ + password: oldPassword, + }); + + await apiSignin({ + page, + email: user.email, + password: oldPassword, + redirectPath: '/settings/profile', + }); + + expect(await checkSessionValid(page)).toBe(true); + + const initialCookies = await page.context().cookies(); + + await page.context().clearCookies(); + + await page.goto('http://localhost:3000/signin'); + await page.getByRole('link', { name: 'Forgot your password?' }).click(); + await expect(page).toHaveURL('http://localhost:3000/forgot-password'); + await page.getByRole('textbox', { name: 'Email' }).fill(user.email); + await page.getByRole('button', { name: 'Reset Password' }).click(); + await expect(page.locator('body')).toContainText('Reset email sent', { + timeout: 10000, + }); + + const foundToken = await prisma.passwordResetToken.findFirstOrThrow({ + where: { userId: user.id }, + }); + + await page.goto(`http://localhost:3000/reset-password/${foundToken.token}`); + await page.getByLabel('Password', { exact: true }).fill(newPassword); + await page.getByLabel('Repeat Password').fill(newPassword); + await page.getByRole('button', { name: 'Reset Password' }).click(); + await expect(page.locator('body')).toContainText('Your password has been updated successfully.'); + + await page.context().addCookies(initialCookies); + + await page.goto('http://localhost:3000/settings/profile'); + await expect(page).toHaveURL('http://localhost:3000/signin'); + + expect(await checkSessionValid(page)).toBe(false); + + await apiSignin({ + page, + email: user.email, + password: newPassword, + redirectPath: '/settings/profile', + }); + + await page.waitForURL('/settings/profile'); + expect(await checkSessionValid(page)).toBe(true); +}); + +test('[USER] password update invalidates other sessions but keeps current', async ({ + page, +}: { + page: Page; +}) => { + const oldPassword = 'Test123!'; + const newPassword = 'Test124!'; + + const { user } = await seedUser({ + password: oldPassword, + }); + + await apiSignin({ + page, + email: user.email, + password: oldPassword, + redirectPath: '/settings/profile', + }); + + expect(await checkSessionValid(page)).toBe(true); + + await page.context().clearCookies(); + await apiSignin({ + page, + email: user.email, + password: oldPassword, + redirectPath: '/settings/profile', + }); + + expect(await checkSessionValid(page)).toBe(true); + + await page.goto('http://localhost:3000/settings/security'); + await page.getByLabel('Current password').fill(oldPassword); + await page.getByLabel('New password').fill(newPassword); + await page.getByLabel('Repeat password').fill(newPassword); + await page.getByRole('button', { name: 'Update password' }).click(); + await expect(page.locator('body')).toContainText('Password updated'); + + await page.goto('http://localhost:3000/settings/security'); + await expect(page).toHaveURL('http://localhost:3000/settings/security'); + + expect(await checkSessionValid(page)).toBe(true); +}); diff --git a/packages/auth/server/routes/email-password.ts b/packages/auth/server/routes/email-password.ts index b43d9f9a5..0b58a1d2d 100644 --- a/packages/auth/server/routes/email-password.ts +++ b/packages/auth/server/routes/email-password.ts @@ -24,6 +24,7 @@ import { env } from '@documenso/lib/utils/env'; import { prisma } from '@documenso/prisma'; import { AuthenticationErrorCode } from '../lib/errors/error-codes'; +import { invalidateSessions } from '../lib/session/session'; import { getCsrfCookie } from '../lib/session/session-cookies'; import { onAuthorize } from '../lib/utils/authorizer'; import { getSession } from '../lib/utils/get-session'; @@ -170,15 +171,38 @@ export const emailPasswordRoute = new Hono() const { password, currentPassword } = c.req.valid('json'); const requestMetadata = c.get('requestMetadata'); - const session = await getSession(c); + const { session, user } = await getSession(c); await updatePassword({ - userId: session.user.id, + userId: user.id, password, currentPassword, requestMetadata, }); + const userSessionIds = await prisma.session + .findMany({ + where: { + userId: user.id, + id: { + not: session.id, + }, + }, + select: { + id: true, + }, + }) + .then((sessions) => sessions.map((s) => s.id)); + + if (userSessionIds.length > 0) { + await invalidateSessions({ + userId: user.id, + sessionIds: userSessionIds, + metadata: requestMetadata, + isRevoke: true, + }); + } + return c.text('OK', 201); }) /** @@ -231,12 +255,41 @@ export const emailPasswordRoute = new Hono() const requestMetadata = c.get('requestMetadata'); + // Look up user ID before password reset for session invalidation + const passwordResetToken = await prisma.passwordResetToken.findFirst({ + where: { token }, + select: { userId: true }, + }); + await resetPassword({ token, password, requestMetadata, }); + // Invalidate all sessions after successful password reset + if (passwordResetToken) { + const userSessionIds = await prisma.session + .findMany({ + where: { + userId: passwordResetToken.userId, + }, + select: { + id: true, + }, + }) + .then((sessions) => sessions.map((session) => session.id)); + + if (userSessionIds.length > 0) { + await invalidateSessions({ + userId: passwordResetToken.userId, + sessionIds: userSessionIds, + metadata: requestMetadata, + isRevoke: true, + }); + } + } + return c.text('OK', 201); }) /**