Files
Zumri-Backend/app/controllers/auth.controller.js
T
Sathira Sri Sathara 9d3d431416 feat: implement identity and security features
- 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.
2026-09-03 13:56:18 +05:30

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