239 lines
8.5 KiB
JavaScript
239 lines
8.5 KiB
JavaScript
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 }));
|
|
},
|
|
});
|
|
}
|