VirtualPR/backend/src/routes/auth.js

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 }));
},
});
}