9d3d431416
- Added account types and privileged account types constants. - Created admin user controller for updating user security fields. - Developed role assignment controller for managing user roles. - Implemented validation middleware for request schemas. - Defined user role and auth session models for database interactions. - Created services for authentication, email notifications, and OTP handling. - Developed OAuth service for Google and Apple authentication. - Added JWT utility functions for token generation and verification. - Implemented comprehensive tests for authentication, session management, and password policies. - Created migration for updating user schema and adding new tables for auth sessions and user identities.
193 lines
11 KiB
JavaScript
193 lines
11 KiB
JavaScript
const db = require("../models");
|
|
const authService = require("../services/auth/auth.service");
|
|
const sessionService = require("../services/auth/session.service");
|
|
const { hashPassword, checkPassword } = require("../utils/hashPassword.util");
|
|
const { createPasswordReset, consumePasswordResetToken, sendPasswordResetEmail } = require("../utils/passwordReset.utill");
|
|
const { createEmailVerification, consumeEmailVerificationToken, sendVerificationEmail } = require("../utils/emailVerification.util");
|
|
const { sendPasswordChanged } = require("../services/auth/email.service");
|
|
const { logActivity } = require("../services/activity.service");
|
|
const { verifyToken } = require("../utils/jwt.util");
|
|
|
|
const cookieOptions = (maxAge, path = "/") => ({ httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: process.env.NODE_ENV === "production" ? "none" : "lax", maxAge, path });
|
|
const clearCookies = (res) => {
|
|
res.clearCookie("access_token", cookieOptions(undefined, "/"));
|
|
res.clearCookie("refresh_token", cookieOptions(undefined, "/api"));
|
|
};
|
|
const projectUser = (user) => ({ id: user.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, emailVerified: Boolean(user.emailVerifiedAt) });
|
|
const deliverTokens = (req, res, result, clientType = "WEB") => {
|
|
const accessMs = 15 * 60 * 1000;
|
|
const refreshMs = Math.max(0, new Date(result.refreshExpiresAt).getTime() - Date.now());
|
|
if (clientType === "WEB") {
|
|
res.cookie("access_token", result.accessToken, cookieOptions(accessMs));
|
|
res.cookie("refresh_token", result.refreshToken, cookieOptions(refreshMs, "/api"));
|
|
return { accessToken: result.accessToken };
|
|
}
|
|
return { accessToken: result.accessToken, refreshToken: result.refreshToken, refreshExpiresAt: result.refreshExpiresAt };
|
|
};
|
|
|
|
exports.login = async (req, res, next) => {
|
|
try {
|
|
const result = await authService.beginPasswordLogin(req.validated.body, req);
|
|
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
exports.loginReq = exports.login;
|
|
|
|
exports.verifyOtp = async (req, res, next) => {
|
|
try {
|
|
const input = req.validated.body;
|
|
const result = await authService.completeOtpLogin(input, req);
|
|
const tokens = deliverTokens(req, res, result, input.clientType);
|
|
await logActivity({ user: result.user, description: "Authentication session created", type: "LOGIN_SUCCEEDED", module: "Authentication" });
|
|
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.refreshToken = async (req, res, next) => {
|
|
try {
|
|
const input = req.validated.body;
|
|
const token = input.refreshToken || req.cookies?.refresh_token;
|
|
if (!token) throw Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" });
|
|
const result = await authService.refresh(token, req);
|
|
res.json({ success: true, data: deliverTokens(req, res, result, input.clientType) });
|
|
} catch (error) {
|
|
clearCookies(res);
|
|
if (error.code === "REFRESH_TOKEN_REUSE") console.warn(`[${req.id}] Refresh token replay detected; token family revoked`);
|
|
next(Object.assign(new Error("Invalid session"), { status: 401, code: "INVALID_SESSION" }));
|
|
}
|
|
};
|
|
|
|
exports.logout = async (req, res, next) => {
|
|
try {
|
|
const token = req.body?.refreshToken || req.cookies?.refresh_token;
|
|
let id = sessionService.tokenId(token);
|
|
if (!id) {
|
|
const accessToken = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
|
|
try { id = accessToken ? verifyToken(accessToken).sid : null; } catch (_error) { id = null; }
|
|
}
|
|
if (id) await sessionService.revokeSession(id, "LOGOUT");
|
|
clearCookies(res);
|
|
res.json({ success: true, message: "Logged out successfully" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.logoutAll = async (req, res, next) => {
|
|
try {
|
|
await db.sequelize.transaction(async (transaction) => {
|
|
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
|
|
user.tokenVersion += 1; await user.save({ transaction });
|
|
await sessionService.revokeAllUserSessions(user.id, "LOGOUT_ALL", transaction);
|
|
});
|
|
clearCookies(res);
|
|
await logActivity({ user: req.user, description: "All authentication sessions revoked", type: "LOGOUT_ALL", module: "Authentication" });
|
|
res.json({ success: true, message: "Logged out from all devices" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.me = async (req, res, next) => {
|
|
try {
|
|
const user = await db.User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: db.Profile, as: "profile" }] });
|
|
res.json({ success: true, data: { ...projectUser(user), profile: user.profile, effectivePermissions: req.user.permissions || [] } });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.forgotPassword = async (req, res, next) => {
|
|
const message = "If an account exists for this email, a password reset link has been sent";
|
|
try {
|
|
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
|
|
if (user) {
|
|
const token = await createPasswordReset(user.id);
|
|
await sendPasswordResetEmail(user.email, user.firstName, token);
|
|
}
|
|
res.json({ success: true, message });
|
|
} catch (error) {
|
|
console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
|
|
res.json({ success: true, message });
|
|
}
|
|
};
|
|
|
|
exports.resetPassword = async (req, res, next) => {
|
|
try {
|
|
const input = req.validated.body;
|
|
const userId = await consumePasswordResetToken(input.token);
|
|
if (!userId) throw Object.assign(new Error("Reset token is invalid or expired"), { status: 400, code: "INVALID_RESET_TOKEN" });
|
|
let changedUser;
|
|
await db.sequelize.transaction(async (transaction) => {
|
|
const user = await db.User.findByPk(userId, { transaction, lock: transaction.LOCK.UPDATE });
|
|
if (!user || (user.password && await checkPassword(input.newPassword, user.password))) throw Object.assign(new Error("Invalid password change"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
|
|
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
|
|
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_RESET", transaction); changedUser = user;
|
|
});
|
|
sendPasswordChanged(changedUser).catch(() => {});
|
|
await logActivity({ user: changedUser, description: "Password reset and sessions revoked", type: "PASSWORD_RESET", module: "Authentication" });
|
|
res.json({ success: true, message: "Password reset successfully. Please login again" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.changePassword = async (req, res, next) => {
|
|
try {
|
|
const input = req.validated.body;
|
|
let changedUser;
|
|
await db.sequelize.transaction(async (transaction) => {
|
|
const user = await db.User.findByPk(req.user.id, { transaction, lock: transaction.LOCK.UPDATE });
|
|
if (!user?.password || !await checkPassword(input.currentPassword, user.password)) throw Object.assign(new Error("Current password is incorrect"), { status: 401, code: "INVALID_CREDENTIALS" });
|
|
if (await checkPassword(input.newPassword, user.password)) throw Object.assign(new Error("New password must be different"), { status: 400, code: "INVALID_PASSWORD_CHANGE" });
|
|
user.password = await hashPassword(input.newPassword); user.passwordChangedAt = new Date(); user.tokenVersion += 1;
|
|
await user.save({ transaction }); await sessionService.revokeAllUserSessions(user.id, "PASSWORD_CHANGED", transaction); changedUser = user;
|
|
});
|
|
clearCookies(res); sendPasswordChanged(changedUser).catch(() => {});
|
|
await logActivity({ user: changedUser, description: "Password changed and sessions revoked", type: "PASSWORD_CHANGED", module: "Authentication" });
|
|
res.json({ success: true, message: "Password changed successfully. Please login again" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.verifyEmail = async (req, res, next) => {
|
|
try {
|
|
const userId = await consumeEmailVerificationToken(req.validated.body.token);
|
|
if (!userId) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
|
|
const user = await db.User.findByPk(userId);
|
|
if (!user) throw Object.assign(new Error("Verification token is invalid or expired"), { status: 400, code: "INVALID_VERIFICATION_TOKEN" });
|
|
if (!user.emailVerifiedAt) { user.emailVerifiedAt = new Date(); user.accountStatus = "ACTIVE"; await user.save(); }
|
|
await logActivity({ user, description: "Email address verified", type: "EMAIL_VERIFIED", module: "Authentication" });
|
|
res.json({ success: true, message: "Email verified" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
|
|
exports.resendVerification = async (req, res, next) => {
|
|
const message = "If verification is required, a new email has been sent";
|
|
try {
|
|
const user = await db.User.findOne({ where: { email: req.validated.body.email } });
|
|
if (user && !user.emailVerifiedAt && user.accountStatus === "PENDING_VERIFICATION") {
|
|
const token = await createEmailVerification(user.id); await sendVerificationEmail(user.email, user.firstName, token);
|
|
}
|
|
res.json({ success: true, message });
|
|
} catch (error) {
|
|
console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message });
|
|
res.json({ success: true, message });
|
|
}
|
|
};
|
|
|
|
exports.oauth = (provider) => async (req, res, next) => {
|
|
try {
|
|
const input = req.validated.body; const result = await authService.authenticateOAuth(provider, input, req);
|
|
const tokens = deliverTokens(req, res, result, input.clientType);
|
|
await logActivity({ user: result.user, description: `${provider} identity authenticated`, type: `${provider.toUpperCase()}_ACCOUNT_LINKED`, module: "Authentication" });
|
|
res.json({ success: true, data: { user: projectUser(result.user), ...tokens } });
|
|
} catch (error) { next(Object.assign(error, { status: error.status || 401, code: error.code || "OAUTH_FAILED" })); }
|
|
};
|
|
|
|
exports.adminLogin = async (req, res, next) => {
|
|
try {
|
|
const { PRIVILEGED_ACCOUNT_TYPES } = require("../constants/accountTypes");
|
|
const result = await authService.beginPasswordLogin(req.validated.body, req, PRIVILEGED_ACCOUNT_TYPES);
|
|
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
|
|
} catch (error) { next(error); }
|
|
};
|
|
exports.riderLogin = async (req, res, next) => {
|
|
try {
|
|
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
|
|
const result = await authService.beginPasswordLogin(req.validated.body, req, [ACCOUNT_TYPES.RIDER]);
|
|
res.status(202).json({ success: true, data: result, message: "If the credentials are valid, a verification code has been sent" });
|
|
} catch (error) { next(error); }
|
|
};
|