fix password reset

This commit is contained in:
root
2026-05-26 20:12:06 -04:00
parent 1a1c418087
commit 3f3b9d9e6a
3 changed files with 111 additions and 12 deletions
@@ -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: {
@@ -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))
})
})
@@ -13,6 +13,11 @@ const RESET_TOKEN_TTL_MINUTES = 60
type EmployeeLoginInput = output<typeof employeeLoginSchema>
type EmployeeLanguageInput = output<typeof employeeLanguageSchema>
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')