fix password reset
This commit is contained in:
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user