import crypto from 'node:crypto'; import Joi from 'joi'; import Boom from '@hapi/boom'; import bcrypt from 'bcryptjs'; import { signToken, credentials, authResponse } from '../plugins/auth.js'; import { env } from '../config/env.js'; import { User } from '../models/User.js'; import { sendPasswordResetEmail } from '../services/mail.js'; import { writeAudit, clientIp } from '../services/audit.js'; import { isMobileNumber } from '../services/cryptoPii.js'; import { hitRateLimit } from '../services/rateLimit.js'; import { applySessionCookies, clearSessionCookies, issueRefreshSession, REFRESH_COOKIE, rotateRefreshSession, revokeRefreshSession, } from '../services/sessionCookies.js'; const GENERIC_RESET_MESSAGE = 'If that account exists, we sent a password reset link.'; function hashResetToken(token) { return crypto.createHash('sha256').update(token).digest('hex'); } function assertAuthRate(request, email) { const ip = clientIp(request); if (hitRateLimit(`auth:${request.path}:${ip}`, 5) || (email && hitRateLimit(`auth:${request.path}:${email}`, 5))) { throw Boom.tooManyRequests('Too many attempts. Try again in a minute.'); } } async function startSession(h, user) { const token = signToken(user); const refresh = await issueRefreshSession(user._id); return applySessionCookies(h.response(authResponse(user, token)), token, refresh); } export function authRoutes(server) { server.route({ method: 'POST', path: '/api/auth/login', options: { auth: false, validate: { payload: Joi.object({ email: Joi.string().email({ tlds: { allow: false } }).required(), password: Joi.string().required(), }), }, }, handler: async (request, h) => { const email = request.payload.email.toLowerCase().trim(); assertAuthRate(request, email); const user = await User.findOne({ email }).populate('role'); if (!user || !(await bcrypt.compare(request.payload.password, user.passwordHash))) { await writeAudit(request, { action: 'login.failure', resourceType: 'user' }); throw Boom.unauthorized('Invalid email or password'); } if (!user.isActive) throw Boom.forbidden('This account is disabled'); user.lastLoginAt = new Date(); await user.save(); await writeAudit(request, { action: 'login.success', resourceType: 'user', resourceId: user._id, actor: user._id }); return startSession(h, user); }, }); server.route({ method: 'POST', path: '/api/auth/refresh', options: { auth: false }, handler: async (request, h) => { const rotated = await rotateRefreshSession(request.state?.[REFRESH_COOKIE]); if (!rotated) { throw Boom.unauthorized(); } const user = await User.findById(rotated.userId).populate('role'); if (!user || !user.isActive) { throw Boom.unauthorized(); } const token = signToken(user); return applySessionCookies(h.response(authResponse(user, token)), token, rotated.raw); }, }); server.route({ method: 'GET', path: '/api/auth/me', handler: async (request) => { const { user } = credentials(request); const fresh = await User.findById(user._id).populate('role'); if (!fresh || !fresh.isActive) throw Boom.unauthorized(); const { token, ...rest } = authResponse(fresh, 'unused'); return rest; }, }); server.route({ method: 'PATCH', path: '/api/auth/profile', options: { validate: { payload: Joi.object({ firstName: Joi.string().trim().min(1).required(), lastName: Joi.string().trim().min(1).required(), phone: Joi.string().allow('', null), specializations: Joi.array().items(Joi.string().trim().min(1)), availableSlots: Joi.array().items(Joi.string().trim().min(1)), availableStartWeekdays: Joi.array().items(Joi.number().integer().min(0).max(6)), maxCohorts: Joi.number().integer().min(1).max(20).allow(null), }), }, }, handler: async (request) => { const { user } = credentials(request); const fresh = await User.findById(user._id).populate('role'); if (!fresh || !fresh.isActive) throw Boom.unauthorized(); fresh.firstName = request.payload.firstName; fresh.lastName = request.payload.lastName; if (request.payload.phone != null && String(request.payload.phone).trim() && !isMobileNumber(request.payload.phone)) { throw Boom.badRequest('Mobile must be 10 digits.'); } if (request.payload.phone != null) fresh.phone = request.payload.phone; if (request.payload.specializations) fresh.specializations = request.payload.specializations; if (request.payload.availableSlots) fresh.availableSlots = request.payload.availableSlots; if (request.payload.availableStartWeekdays) fresh.availableStartWeekdays = request.payload.availableStartWeekdays; if (request.payload.maxCohorts !== undefined) fresh.maxCohorts = request.payload.maxCohorts; await fresh.save(); await fresh.populate('role'); const { token, ...rest } = authResponse(fresh, 'unused'); return rest; }, }); server.route({ method: 'POST', path: '/api/auth/change-password', options: { validate: { payload: Joi.object({ currentPassword: Joi.string().required(), newPassword: Joi.string().min(6).required(), }), }, }, handler: async (request, h) => { const { user } = credentials(request); const fresh = await User.findById(user._id); if (!fresh || !fresh.isActive) throw Boom.unauthorized(); if (!(await bcrypt.compare(request.payload.currentPassword, fresh.passwordHash))) { throw Boom.badRequest('Current password is incorrect'); } if (request.payload.currentPassword === request.payload.newPassword) { throw Boom.badRequest('New password must be different from the current password'); } fresh.passwordHash = await bcrypt.hash(request.payload.newPassword, 10); await fresh.save(); const token = signToken(fresh); const refresh = await issueRefreshSession(fresh._id); return applySessionCookies(h.response({ ok: true }), token, refresh); }, }); server.route({ method: 'POST', path: '/api/auth/forgot-password', options: { auth: false, validate: { payload: Joi.object({ email: Joi.string().email({ tlds: { allow: false } }).required(), }), }, }, handler: async (request) => { const email = request.payload.email.toLowerCase().trim(); assertAuthRate(request, email); const user = await User.findOne({ email, isActive: true }); if (user) { const rawToken = crypto.randomBytes(32).toString('hex'); user.passwordResetToken = hashResetToken(rawToken); user.passwordResetExpires = new Date(Date.now() + 60 * 60 * 1000); await user.save(); const resetUrl = `${env.APP_URL}/reset-password?token=${rawToken}`; try { await sendPasswordResetEmail({ to: user.email, name: user.firstName, resetUrl, }); } catch (err) { request.log(['error', 'mail'], err); throw Boom.badImplementation('Could not send the reset email. Try again later.'); } } return { ok: true, message: GENERIC_RESET_MESSAGE }; }, }); server.route({ method: 'POST', path: '/api/auth/reset-password', options: { auth: false, validate: { payload: Joi.object({ token: Joi.string().hex().min(32).required(), newPassword: Joi.string().min(6).required(), }), }, }, handler: async (request) => { const hashed = hashResetToken(request.payload.token); const user = await User.findOne({ passwordResetToken: hashed, passwordResetExpires: { $gt: new Date() }, isActive: true, }); if (!user) throw Boom.badRequest('This reset link is invalid or has expired.'); user.passwordHash = await bcrypt.hash(request.payload.newPassword, 10); user.passwordResetToken = null; user.passwordResetExpires = null; await user.save(); return { ok: true }; }, }); server.route({ method: 'POST', path: '/api/auth/logout', options: { auth: false }, handler: async (request, h) => { await revokeRefreshSession(request.state?.[REFRESH_COOKIE]); return clearSessionCookies(h.response({ ok: true })); }, }); }