From 3f3b9d9e6a193b0f3b780d18b36dc6ce0bc90936 Mon Sep 17 00:00:00 2001 From: root Date: Tue, 26 May 2026 20:12:06 -0400 Subject: [PATCH] fix password reset --- .../src/modules/auth/auth.employee.repo.ts | 6 ++ .../auth/auth.employee.service.test.ts | 46 ++++++++++-- .../src/modules/auth/auth.employee.service.ts | 71 +++++++++++++++++-- 3 files changed, 111 insertions(+), 12 deletions(-) diff --git a/apps/api/src/modules/auth/auth.employee.repo.ts b/apps/api/src/modules/auth/auth.employee.repo.ts index b93f8b2..c456286 100644 --- a/apps/api/src/modules/auth/auth.employee.repo.ts +++ b/apps/api/src/modules/auth/auth.employee.repo.ts @@ -7,6 +7,12 @@ export function findEmployeeWithCompanyById(id: string) { }) } +export function findEmployeeById(id: string) { + return prisma.employee.findUnique({ + where: { id }, + }) +} + export function findEmployeeWithCompanyByEmail(email: string) { return prisma.employee.findFirst({ where: { diff --git a/apps/api/src/modules/auth/auth.employee.service.test.ts b/apps/api/src/modules/auth/auth.employee.service.test.ts index c2e8758..fc38c53 100644 --- a/apps/api/src/modules/auth/auth.employee.service.test.ts +++ b/apps/api/src/modules/auth/auth.employee.service.test.ts @@ -2,7 +2,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('./auth.employee.repo', () => ({ findActiveEmployeeByEmail: vi.fn(), - setPasswordResetToken: vi.fn(), + findEmployeeById: vi.fn(), + findEmployeeByResetToken: vi.fn(), + resetPassword: vi.fn(), })) vi.mock('../../services/notificationService', () => ({ @@ -11,18 +13,21 @@ vi.mock('../../services/notificationService', () => ({ import * as repo from './auth.employee.repo' import { sendTransactionalEmail } from '../../services/notificationService' -import { forgotPassword } from './auth.employee.service' +import { forgotPassword, resetPassword } from './auth.employee.service' describe('auth.employee.service forgotPassword', () => { const originalDashboardUrl = process.env.DASHBOARD_URL + const originalJwtSecret = process.env.JWT_SECRET beforeEach(() => { vi.clearAllMocks() process.env.DASHBOARD_URL = 'http://localhost:3000/dashboard' + process.env.JWT_SECRET = 'test-secret' }) afterAll(() => { process.env.DASHBOARD_URL = originalDashboardUrl + process.env.JWT_SECRET = originalJwtSecret }) it('sends the reset email to the canonical stored address', async () => { @@ -31,19 +36,46 @@ describe('auth.employee.service forgotPassword', () => { email: 'owner@company.com', firstName: 'Aya', preferredLanguage: 'en', + passwordHash: '$2a$12$abcdefghijklmnopqrstuv', } as any) await forgotPassword('Owner@Company.com') - expect(repo.setPasswordResetToken).toHaveBeenCalledWith( - 'emp_1', - expect.any(String), - expect.any(Date), - ) expect(sendTransactionalEmail).toHaveBeenCalledWith( expect.objectContaining({ to: 'owner@company.com', + html: expect.stringContaining('/dashboard/reset-password?token='), + text: expect.stringContaining('/dashboard/reset-password?token='), }), ) }) + + it('accepts stateless reset tokens', async () => { + const employee = { + id: 'emp_1', + isActive: true, + passwordHash: '$2a$12$abcdefghijklmnopqrstuv', + } as any + + vi.mocked(repo.findActiveEmployeeByEmail).mockResolvedValue({ + ...employee, + email: 'owner@company.com', + firstName: 'Aya', + preferredLanguage: 'en', + }) + vi.mocked(repo.findEmployeeByResetToken).mockResolvedValue(null) + vi.mocked(repo.findEmployeeById).mockResolvedValue(employee) + vi.mocked(repo.resetPassword).mockResolvedValue({} as any) + + await forgotPassword('Owner@Company.com') + + const emailCall = vi.mocked(sendTransactionalEmail).mock.calls[0]?.[0] + const match = emailCall?.text.match(/token=([^\s]+)/) + expect(match?.[1]).toBeTruthy() + + await expect(resetPassword(decodeURIComponent(match![1]), 'new-password-123')).resolves.toEqual({ + message: 'Password updated successfully. You can now sign in.', + }) + expect(repo.resetPassword).toHaveBeenCalledWith('emp_1', expect.any(String)) + }) }) diff --git a/apps/api/src/modules/auth/auth.employee.service.ts b/apps/api/src/modules/auth/auth.employee.service.ts index fd03f71..0d78544 100644 --- a/apps/api/src/modules/auth/auth.employee.service.ts +++ b/apps/api/src/modules/auth/auth.employee.service.ts @@ -13,6 +13,11 @@ const RESET_TOKEN_TTL_MINUTES = 60 type EmployeeLoginInput = output type EmployeeLanguageInput = output +type EmployeePasswordResetPayload = jwt.JwtPayload & { + sub: string + type: 'employee_password_reset' + pwdv: string +} function signEmployeeToken(employeeId: string) { return jwt.sign( @@ -22,6 +27,43 @@ function signEmployeeToken(employeeId: string) { ) } +function getEmployeePasswordResetVersion(passwordHash: string | null | undefined) { + return crypto + .createHash('sha256') + .update(`${process.env.JWT_SECRET!}:${passwordHash ?? 'no-password-set'}`) + .digest('hex') +} + +function signEmployeePasswordResetToken(employeeId: string, passwordHash: string | null | undefined) { + return jwt.sign( + { + sub: employeeId, + type: 'employee_password_reset', + pwdv: getEmployeePasswordResetVersion(passwordHash), + }, + process.env.JWT_SECRET!, + { expiresIn: `${RESET_TOKEN_TTL_MINUTES}m` }, + ) +} + +function verifyEmployeePasswordResetToken(token: string): EmployeePasswordResetPayload | null { + try { + const payload = jwt.verify(token, process.env.JWT_SECRET!) as jwt.JwtPayload + + if ( + typeof payload.sub !== 'string' || + payload.type !== 'employee_password_reset' || + typeof payload.pwdv !== 'string' + ) { + return null + } + + return payload as EmployeePasswordResetPayload + } catch { + return null + } +} + function ensureDashboardBasePath(baseUrl: string) { try { const url = new URL(baseUrl) @@ -67,14 +109,12 @@ export async function forgotPassword(email: string) { const employee = await repo.findActiveEmployeeByEmail(email) if (employee) { - const rawToken = crypto.randomBytes(32).toString('hex') - const expiresAt = new Date(Date.now() + RESET_TOKEN_TTL_MINUTES * 60 * 1000) - await repo.setPasswordResetToken(employee.id, rawToken, expiresAt) + const resetToken = signEmployeePasswordResetToken(employee.id, employee.passwordHash) const dashboardUrl = ensureDashboardBasePath( process.env.DASHBOARD_URL ?? process.env.NEXT_PUBLIC_DASHBOARD_URL ?? 'http://localhost:3000/dashboard', ) - const resetUrl = `${dashboardUrl}/reset-password?token=${rawToken}` + const resetUrl = `${dashboardUrl}/reset-password?token=${encodeURIComponent(resetToken)}` const lang = (employee.preferredLanguage as Lang) ?? 'fr' await sendTransactionalEmail({ @@ -96,8 +136,29 @@ export async function updateLanguage(employeeId: string, language: EmployeeLangu return { language } } +async function findEmployeeFromResetToken(token: string) { + try { + const employee = await repo.findEmployeeByResetToken(token) + if (employee) return employee + } catch (err: any) { + console.error('[EmployeeResetPassword] Stored token lookup failed:', err?.message ?? String(err)) + } + + const payload = verifyEmployeePasswordResetToken(token) + if (!payload) return null + + const employee = await repo.findEmployeeById(payload.sub) + if (!employee || !employee.isActive) return null + + if (payload.pwdv !== getEmployeePasswordResetVersion(employee.passwordHash)) { + return null + } + + return employee +} + export async function resetPassword(token: string, password: string) { - const employee = await repo.findEmployeeByResetToken(token) + const employee = await findEmployeeFromResetToken(token) if (!employee) { throw new AppError('Reset link is invalid or has expired.', 400, 'invalid_token')