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.
This commit is contained in:
Sathira Sri Sathara
2026-09-03 13:56:18 +05:30
parent 267e80e2ec
commit 9d3d431416
54 changed files with 1389 additions and 1076 deletions
+8 -1
View File
@@ -11,7 +11,13 @@ const envSchema = z.object({
DB_USER: z.string().min(1),
DB_PASSWORD: z.string(),
JWT_SECRET: z.string().min(32, "JWT_SECRET must contain at least 32 characters"),
REFRESH_TOKEN_SECRET: z.string().min(32, "REFRESH_TOKEN_SECRET must contain at least 32 characters"),
JWT_ISSUER: z.string().min(1).default("zumri-api"),
JWT_AUDIENCE: z.string().min(1).default("zumri-clients"),
ACCESS_TOKEN_TTL: z.string().default("15m"),
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(7),
REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
LOGIN_OTP_TTL_SECONDS: z.coerce.number().int().positive().default(900),
LOGIN_OTP_MAX_ATTEMPTS: z.coerce.number().int().min(1).max(10).default(5),
REDIS_HOST: z.string().min(1),
REDIS_PORT: z.coerce.number().int().min(1).max(65535).default(6379),
REDIS_PASSWORD: z.string().optional(),
@@ -33,6 +39,7 @@ const envSchema = z.object({
AWS_REGION: z.string().optional(), AWS_ACCESS_KEY_ID: z.string().optional(),
AWS_SECRET_ACCESS_KEY: z.string().optional(), AWS_S3_BUCKET_NAME: z.string().optional(),
DOCS_USER: z.string().optional(), DOCS_PASS: z.string().optional(),
GOOGLE_CLIENT_ID: z.string().optional(), APPLE_CLIENT_ID: z.string().optional(),
}).superRefine((env, context) => {
const requireFeature = (enabled, names) => {
if (!enabled) return;
+15
View File
@@ -0,0 +1,15 @@
const ACCOUNT_TYPES = Object.freeze({
SUPER_ADMIN: "superadmin",
ADMIN: "admin",
MANAGER: "manager",
CUSTOMER: "customer",
BUSINESS_CUSTOMER: "business_customer",
RIDER: "rider",
SUPPORT_AGENT: "support_agent",
});
const PRIVILEGED_ACCOUNT_TYPES = Object.freeze([
ACCOUNT_TYPES.SUPER_ADMIN, ACCOUNT_TYPES.ADMIN, ACCOUNT_TYPES.MANAGER, ACCOUNT_TYPES.SUPPORT_AGENT,
]);
module.exports = { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES, ACCOUNT_TYPE_VALUES: Object.values(ACCOUNT_TYPES) };
+24
View File
@@ -0,0 +1,24 @@
const db = require("../models");
const { ACCOUNT_TYPE_VALUES } = require("../constants/accountTypes");
const { revokeAllUserSessions } = require("../services/auth/session.service");
const { logActivity } = require("../services/activity.service");
const updateSecurityField = (field) => async (req, res, next) => {
try {
const value = req.body[field];
if (field === "accountType" && !ACCOUNT_TYPE_VALUES.includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_TYPE", message: "Invalid account type" } });
if (field === "accountStatus" && !["PENDING_VERIFICATION", "ACTIVE", "SUSPENDED", "DEACTIVATED"].includes(value)) return res.status(400).json({ success: false, error: { code: "INVALID_ACCOUNT_STATUS", message: "Invalid account status" } });
if (req.params.id === req.user.id) return res.status(400).json({ success: false, error: { code: "SELF_SECURITY_CHANGE_DENIED", message: "Security-sensitive self changes are not allowed" } });
let target; let previous;
await db.sequelize.transaction(async (transaction) => {
target = await db.User.findByPk(req.params.id, { transaction, lock: transaction.LOCK.UPDATE });
if (!target) throw Object.assign(new Error("User not found"), { status: 404, code: "USER_NOT_FOUND" });
previous = target[field]; target[field] = value; target.tokenVersion += 1; await target.save({ transaction });
await revokeAllUserSessions(target.id, `ADMIN_${field.toUpperCase()}_CHANGE`, transaction);
});
await logActivity({ user: req.user, description: `${field} changed for ${target.id} from ${previous} to ${value}; reason: ${req.body.reason || "not supplied"}; request: ${req.id}`, type: field === "accountStatus" ? "ACCOUNT_STATUS_CHANGED" : "ACCOUNT_TYPE_CHANGED", module: "Identity Administration" });
res.json({ success: true, data: { id: target.id, [field]: target[field] } });
} catch (error) { next(error); }
};
exports.updateStatus = updateSecurityField("accountStatus");
exports.updateAccountType = updateSecurityField("accountType");
+159 -503
View File
@@ -1,536 +1,192 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/controllers/auth.controller.js
const { checkPassword, hashPassword } = require("../utils/hashPassword.util");
const { sendMail } = require("../utils/mail.util");
const {
validatePassword,
} = require("../utils/validation/validatePassword.util");
const { validateEmail } = require("../utils/validation/validateEmail.util");
const { generateOTP, validateOTP } = require("../utils/otp.util");
const { getCachedUser, clearUserCache } = require("../utils/cache.util");
const { generateToken } = require("../utils/jwt.util");
const {
createRefreshSession,
validateRefreshSession,
deleteRefreshSession,
deleteAllUserSessions,
} = require("../utils/refreshSession.util");
const {
createPasswordReset,
verifyPasswordResetToken,
deletePasswordReset,
sendPasswordResetEmail,
sendPasswordChangedEmail,
} = require("../utils/passwordReset.utill");
const db = require("../models");
const { log } = require("../utils/consoleLog.utill");
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 appName = process.env.APP_NAME || "Niolla";
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 };
};
const User = db.User;
// Login Step 1: Request OTP
exports.loginReq = async (req, res) => {
exports.login = async (req, res, next) => {
try {
const { email, password } = req.body;
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;
const user = await getCachedUser(email);
if (!user) {
return res
.status(404)
.send({ success: false, message: "User Not Found" });
}
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); }
};
const passwordIsValid = await checkPassword(password, user.password);
if (!passwordIsValid) {
return res
.status(401)
.send({ success: false, message: "Invalid Password" });
}
const otp = generateOTP(email);
await sendMail({
to: email,
subject: `OTP for Your ${appName} Account`,
templateName: "otp",
templateVars: {
firstName: user.firstName,
otp: otp,
},
text: `Hello ${user.firstName}, your otp is ${otp}`,
});
log(`OTP for ${email}: ${otp}`);
log(`OTP sent to ${email} successfully.`);
res.status(201).send({ success: true, message: "OTP Sent Successfully" });
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) {
log("Error occurred while sending OTP:", error);
res.status(500).send({ success: false, message: error.message });
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" }));
}
};
// Login Step 2: Verify OTP and issue JWT
exports.login = async (req, res) => {
exports.logout = async (req, res, next) => {
try {
const { email, otp } = req.body;
if (!email || !otp) {
return res
.status(400)
.send({ success: false, message: "Email and OTP are required" });
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); }
};
const user = await getCachedUser(email);
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); }
};
if (!user) {
return res
.status(404)
.send({ success: false, message: "User Not Found" });
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);
}
if (user.accountStatus !== "ACTIVE") {
return res.status(403).send({
success: false,
message: "Account is not active",
});
}
const isValidOTP = validateOTP(email, String(otp));
if (!isValidOTP) {
return res
.status(401)
.send({ success: false, message: "Invalid or Expired OTP" });
}
// Generate JWT token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
accountType: user.accountType,
});
const { refreshToken } = createRefreshSession(user.id);
// 3. Set JWT as HttpOnly cookie
res.cookie("access_token", token, {
httpOnly: true, // JS cannot access
secure: process.env.NODE_ENV === "production", // HTTPS only in prod
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 15 * 60 * 1000, // 1 day
});
res.cookie("refresh_token", refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
log(`JWT issued for ${email}`);
clearUserCache(email);
res.status(200).send({
success: true,
message: "Login Successful",
data: {
id: user.id,
email: user.email,
firstName: user.firstName,
lastName: user.lastName,
role: user.role,
accountType: user.accountType,
accessToken: token,
},
});
res.json({ success: true, message });
} catch (error) {
log("Error occurred during login:", error);
res.status(500).send({ success: false, message: error.message });
console.error(`[${req.id}] Password reset request failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.refreshToken = async (req, res) => {
exports.resetPassword = async (req, res, next) => {
try {
const refreshToken = req.cookies?.refresh_token;
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); }
};
if (!refreshToken) {
return res.status(401).send({
success: false,
message: "Refresh token is required",
});
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);
}
const session = validateRefreshSession(refreshToken);
if (!session) {
res.clearCookie("refresh_token");
return res.status(401).send({
success: false,
message: "Invalid or expired session. Please login again.",
});
}
const user = await User.findByPk(session.userId);
if (!user || user.accountStatus !== "ACTIVE") {
deleteRefreshSession(session.sessionId);
return res.status(401).send({
success: false,
message: "Session is no longer valid",
});
}
// Generate new Access Token
const token = generateToken({
id: user.id,
firstName: user.firstName,
lastName: user.lastName,
email: user.email,
accountType: user.accountType,
});
// Generate new Refresh Token
const { refreshToken: newRefreshToken } = createRefreshSession(user.id);
deleteRefreshSession(session.sessionId);
// Replace access cookie
res.cookie("access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 15 * 60 * 1000,
});
// Replace refresh cookie
res.cookie("refresh_token", newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
maxAge: 7 * 24 * 60 * 60 * 1000,
});
return res.status(200).send({
success: true,
message: "Session refreshed successfully",
});
res.json({ success: true, message });
} catch (error) {
console.error("REFRESH ERROR:", error);
return res.status(401).send({
success: false,
message: "Invalid or expired session. Please login again.",
});
console.error(`[${req.id}] Verification resend failed`, { name: error.name, message: error.message });
res.json({ success: true, message });
}
};
exports.forgotPassword = async (req, res) => {
exports.oauth = (provider) => async (req, res, next) => {
try {
let { email } = req.body;
if (!email) {
return res.status(400).send({
success: false,
message: "Email is required",
});
}
email = email.trim().toLowerCase();
if (!validateEmail(email)) {
return res.status(400).send({
success: false,
message: "Invalid email address",
});
}
const user = await User.findOne({
where: { email },
});
if (!user) {
return res.status(200).send({
success: true,
message:
"If an account exists for this email, a password reset link has been sent.",
});
}
const resetToken = await createPasswordReset(user.id);
await sendPasswordResetEmail(user.email, user.firstName, resetToken);
return res.status(200).send({
success: true,
message:
"If an account exists for this email, a password reset link has been sent.",
});
} catch (error) {
console.error("FORGOT PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Unable to process password reset request",
});
}
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.resetPassword = async (req, res) => {
exports.adminLogin = async (req, res, next) => {
try {
const { token, newPassword, confirmPassword } = req.body;
if (!token || !newPassword || !confirmPassword) {
return res.status(400).send({
success: false,
message: "Token, new password and confirm password are required",
});
}
if (newPassword !== confirmPassword) {
return res.status(400).send({
success: false,
message: "Passwords do not match",
});
}
if (!validatePassword(newPassword)) {
return res.status(400).send({
success: false,
message: "Password does not meet the required criteria",
});
}
const verification = await verifyPasswordResetToken(token);
if (!verification) {
return res.status(400).send({
success: false,
message: "Reset token is invalid or expired",
});
}
const { userId, redisKey } = verification;
const user = await User.findByPk(userId);
if (!user) {
await deletePasswordReset(redisKey);
return res.status(400).send({
success: false,
message: "Reset token is invalid or expired",
});
}
const samePassword = await checkPassword(newPassword, user.password);
if (samePassword) {
return res.status(400).send({
success: false,
message: "New password must be different from the current password",
});
}
const hashedPassword = await hashPassword(newPassword);
user.password = hashedPassword;
user.passwordChangedAt = new Date();
await user.save();
await sendPasswordChangedEmail(user.email, user.firstName);
await deletePasswordReset(redisKey);
deleteAllUserSessions(user.id);
return res.status(200).send({
success: true,
message: "Password reset successfully. Please login again.",
});
} catch (error) {
console.error("RESET PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Failed to reset password",
});
}
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.changePassword = async (req, res) => {
exports.riderLogin = async (req, res, next) => {
try {
// User ID comes from authenticate middleware
const userId = req.user.id;
const { currentPassword, newPassword, confirmPassword } = req.body;
// 1. Check required fields
if (!currentPassword || !newPassword || !confirmPassword) {
return res.status(400).send({
success: false,
message:
"Current password, new password and confirm password are required",
});
}
// 2. Check new password and confirmation
if (newPassword !== confirmPassword) {
return res.status(400).send({
success: false,
message: "New password and confirm password do not match",
});
}
// 3. Validate password policy
const passwordValid = validatePassword(newPassword);
if (!passwordValid) {
return res.status(400).send({
success: false,
message: "New password does not meet the required criteria",
});
}
// 4. Get logged-in user from database
const user = await User.findByPk(userId);
if (!user) {
return res.status(404).send({
success: false,
message: "User not found",
});
}
// 5. Check current password
const currentPasswordValid = await checkPassword(
currentPassword,
user.password,
);
if (!currentPasswordValid) {
return res.status(401).send({
success: false,
message: "Current password is incorrect",
});
}
// 6. Make sure new password is different
const sameAsOldPassword = await checkPassword(newPassword, user.password);
if (sameAsOldPassword) {
return res.status(400).send({
success: false,
message: "New password must be different from current password",
});
}
// 7. Hash new password
const hashedPassword = await hashPassword(newPassword);
// 8. Update user
user.password = hashedPassword;
user.passwordChangedAt = new Date();
await user.save();
// 9. Revoke all refresh sessions
deleteAllUserSessions(user.id);
// 10. Clear auth cookies
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
res.clearCookie("refresh_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// 11. Send confirmation email
try {
await sendPasswordChangedEmail(user.email, user.firstName);
} catch (mailError) {
console.error("PASSWORD CHANGED EMAIL ERROR:", mailError);
}
// 12. Response
return res.status(200).send({
success: true,
message: "Password changed successfully. Please login again.",
});
} catch (error) {
console.error("CHANGE PASSWORD ERROR:", error);
return res.status(500).send({
success: false,
message: "Failed to change password",
});
}
};
// Logout: Clear the JWT cookie
exports.logout = async (req, res) => {
try {
// 1. Get refresh token from cookie
const refreshToken = req.cookies?.refresh_token;
// 2. If refresh token exists, find its session
if (refreshToken) {
const session = validateRefreshSession(refreshToken);
// 3. Delete refresh session from server RAM
if (session) {
deleteRefreshSession(session.sessionId);
console.log(`Refresh session deleted: ${session.sessionId}`);
}
}
// 4. Clear access token cookie
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// 5. Clear refresh token cookie
res.clearCookie("refresh_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// 6. Send response
return res.status(200).json({
success: true,
message: "Logged out successfully",
});
} catch (error) {
console.error("LOGOUT ERROR:", error);
return res.status(500).json({
success: false,
message: "Failed to logout",
});
}
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); }
};
@@ -0,0 +1,21 @@
const db = require("../models");
const { clearPermissionCache } = require("../utils/cache.util");
const { logActivity } = require("../services/activity.service");
exports.assignRole = async (req, res, next) => {
try {
const [assignment] = await db.UserRole.findOrCreate({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
await clearPermissionCache(req.params.userId);
await logActivity({ user: req.user, description: `Role ${req.params.roleId} assigned to user ${req.params.userId}`, type: "ROLE_ASSIGNED", module: "Authorization" });
res.status(201).json({ success: true, data: { id: assignment.id, userId: assignment.user_id, roleId: assignment.role_id } });
} catch (error) { next(error); }
};
exports.removeRole = async (req, res, next) => {
try {
await db.UserRole.destroy({ where: { user_id: req.params.userId, role_id: req.params.roleId } });
await clearPermissionCache(req.params.userId);
await logActivity({ user: req.user, description: `Role ${req.params.roleId} removed from user ${req.params.userId}`, type: "ROLE_REMOVED", module: "Authorization" });
res.json({ success: true, message: "Role removed" });
} catch (error) { next(error); }
};
+29 -25
View File
@@ -44,7 +44,6 @@ exports.createNewUser = async (req, res) => {
lastName,
email,
password,
accountType,
address,
phoneNumber,
businessName,
@@ -56,23 +55,23 @@ exports.createNewUser = async (req, res) => {
note,
} = req.body;
if (!firstName || !lastName || !email || !password || !accountType) {
if (!firstName || !lastName || !email || !password) {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"First name, last name, email, password and account type are required",
"First name, last name, email and password are required",
});
}
if (accountType !== "customer" && accountType !== "business_customer") {
if (req.body.accountType && req.body.accountType !== "customer") {
await transaction.rollback();
return res.status(400).send({
success: false,
message:
"Invalid account type. Must be either 'customer' or 'business_customer'",
"Public registration creates customer accounts only",
});
}
@@ -118,7 +117,7 @@ exports.createNewUser = async (req, res) => {
lastName,
email,
password: hashedPassword,
accountType,
accountType: "customer",
accountStatus: "PENDING_VERIFICATION",
emailVerifiedAt: null,
},
@@ -139,8 +138,8 @@ exports.createNewUser = async (req, res) => {
{ transaction },
);
//create customer and business customer
if (accountType === "customer") {
// Create the customer identity extension for public registration.
{
const customerData = {
address,phoneNumber,
};
@@ -150,23 +149,6 @@ exports.createNewUser = async (req, res) => {
customerData,
transaction,
);
} else if (accountType === "business_customer") {
const businessData = {
businessName,
businessRegistrationNumber,
businessType,
contactName,
phoneNumber,
businessEmail,
expectedMonthlyVolume,
note,
};
await createBusinessCustomerDetails(
newUser.id,
businessData,
transaction,
);
}
await transaction.commit();
@@ -449,6 +431,7 @@ exports.updateUser = async (req, res) => {
});
if (!user) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User not found",
@@ -456,6 +439,7 @@ exports.updateUser = async (req, res) => {
}
if (!UserProfile) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User profile not found",
@@ -533,6 +517,7 @@ exports.deleteUser = async (req, res) => {
where: { id },
});
if (!user) {
await transaction.rollback();
return res.status(404).send({
success: false,
message: "User not found",
@@ -560,3 +545,22 @@ exports.deleteUser = async (req, res) => {
});
}
};
exports.getCurrentUser = async (req, res, next) => {
try {
const user = await User.findByPk(req.user.id, { attributes: { exclude: ["password", "tokenVersion", "passwordChangedAt"] }, include: [{ model: Profile, as: "profile" }] });
res.json({ success: true, data: user });
} catch (error) { next(error); }
};
exports.updateCurrentUser = async (req, res, next) => {
try {
const allowed = ["firstName", "lastName"];
const supplied = Object.keys(req.body);
if (supplied.some((key) => !allowed.includes(key))) return res.status(400).json({ success: false, error: { code: "UNSAFE_FIELD", message: "Only firstName and lastName may be updated" } });
const updates = Object.fromEntries(supplied.map((key) => [key, req.body[key]]).filter(([, value]) => typeof value === "string" && value.trim()));
await User.update(updates, { where: { id: req.user.id } });
const user = await User.findByPk(req.user.id, { attributes: ["id", "firstName", "lastName", "email", "accountType", "accountStatus"] });
res.json({ success: true, data: user });
} catch (error) { next(error); }
};
+15 -82
View File
@@ -1,93 +1,26 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/middleware/auth.middleware.js
const { verifyToken } = require("../utils/jwt.util");
const { getEffectivePermissions } = require("../services/permission.service");
const db = require("../models");
const authenticate = async (req, res, next) => {
try {
let token = null;
// Get token from cookie
if (req.cookies?.access_token) {
token = req.cookies.access_token;
}
// Fallback to Bearer token
if (!token && req.headers.authorization?.startsWith("Bearer ")) {
token = req.headers.authorization.split(" ")[1];
}
if (!token) {
return res.status(401).json({
success: false,
message: "Unauthorized",
});
}
// Verify token
const token = req.cookies?.access_token || (req.headers.authorization?.startsWith("Bearer ") ? req.headers.authorization.slice(7) : null);
if (!token) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
const decoded = verifyToken(token);
if (process.env.NODE_ENV === "development") {
console.log("DECODED:", decoded);
if (!decoded.sub || !decoded.sid || !Number.isInteger(decoded.tokenVersion)) throw new Error("Required claims missing");
const [user, session] = await Promise.all([
db.User.findByPk(decoded.sub),
db.AuthSession.findByPk(decoded.sid),
]);
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== decoded.tokenVersion || !session || session.user_id !== user.id || session.revoked_at || session.expires_at <= new Date() || session.token_version !== user.tokenVersion) {
return res.status(401).json({ success: false, error: { code: "SESSION_INVALID", message: "Session is no longer valid" } });
}
const userId = decoded.sub || decoded.id;
if (!userId) {
throw new Error("User ID missing in token");
}
// Load permissions
const permissions = await getEffectivePermissions(userId);
req.user = {
...decoded,
id: userId,
permissions,
};
req.user = { id: user.id, sessionId: session.id, firstName: user.firstName, lastName: user.lastName, email: user.email, accountType: user.accountType, accountStatus: user.accountStatus, permissions: await getEffectivePermissions(user.id) };
next();
} catch (err) {
console.error("AUTH ERROR:", err);
// Clear invalid/expired cookie
res.clearCookie("access_token", {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: process.env.NODE_ENV === "production" ? "None" : "Lax",
});
// Token expired
if (err.name === "TokenExpiredError") {
return res.status(401).json({
success: false,
message: "Session expired. Please login again.",
});
}
// Invalid token
if (err.name === "JsonWebTokenError") {
return res.status(401).json({
success: false,
message: "Invalid token",
});
}
// Default
return res.status(401).json({
success: false,
message: "Authentication failed",
});
} catch (error) {
res.clearCookie("access_token");
return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Invalid or expired access token" } });
}
};
module.exports = { authenticate };
module.exports = { authenticate, requireAuth: authenticate };
+18 -93
View File
@@ -1,100 +1,25 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
const { ACCOUNT_TYPES } = require("../constants/accountTypes");
// app/middleware/permission.middleware.js
const hasPermission = (userPermissions, requiredPermission) => {
return userPermissions.some(p => {
if (p === requiredPermission) return true;
// wildcard support
if (p.endsWith(".*")) {
const prefix = p.slice(0, -2);
return requiredPermission.startsWith(prefix);
}
return false;
});
const authorizedAccountType = (allowedTypes) => (req, res, next) => {
if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
if (req.user.accountType !== ACCOUNT_TYPES.SUPER_ADMIN && !allowedTypes.includes(req.user.accountType)) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
next();
};
const methodToAction = {
GET: "view",
POST: "create",
PUT: "update",
PATCH: "update",
DELETE: "delete"
const checkPermission = (baseOrFull, options = {}) => (req, res, next) => {
if (!req.user) return res.status(401).json({ success: false, error: { code: "UNAUTHORIZED", message: "Authentication required" } });
if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN) return next();
const actions = { GET: "view", POST: "create", PUT: "update", PATCH: "update", DELETE: "delete" };
const required = options.custom ? baseOrFull : `${baseOrFull}.${actions[req.method]}`;
const permissions = req.user.permissions || [];
const allowed = permissions.includes(required) || permissions.some((value) => value.endsWith(".*") && required.startsWith(value.slice(0, -1)));
if (!allowed) return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
next();
};
const checkPermission = (baseOrFull, options = {}) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: "Unauthorized"
});
}
// admin bypass
if (req.user.accountType === "admin") {
return next();
}
if (typeof baseOrFull !== "string") {
return res.status(500).json({
success: false,
message: "Permission must be a string"
});
}
let requiredPermission;
if (options.custom) {
requiredPermission = baseOrFull;
} else {
const action = methodToAction[req.method];
if (!action) {
return res.status(500).json({
success: false,
message: "Unknown HTTP method"
});
}
requiredPermission = `${baseOrFull}.${action}`;
}
const userPermissions = req.user.permissions || [];
const hasPermission =
userPermissions.includes(requiredPermission) ||
userPermissions.includes(`${baseOrFull}.*`);
if (!hasPermission) {
return res.status(403).json({
success: false,
message: `Forbidden - Missing ${requiredPermission}`
});
}
next();
};
const requireOwnership = (param = "id") => (req, res, next) => {
if (req.user.accountType === ACCOUNT_TYPES.SUPER_ADMIN || req.user.accountType === ACCOUNT_TYPES.ADMIN || req.user.id === req.params[param]) return next();
return res.status(403).json({ success: false, error: { code: "FORBIDDEN", message: "Access denied" } });
};
const authorizedAccountType = (allowedTypes) => {
return (req, res, next) => {
if (!req.user || !allowedTypes.includes(req.user.accountType)) {
return res
.status(403)
.json({ success: false, message: "Forbidden" });
}
next();
}
}
module.exports = {authorizedAccountType, checkPermission};
module.exports = { authorizedAccountType, requireAccountType: authorizedAccountType, checkPermission, requirePermission: checkPermission, requireOwnership };
+7
View File
@@ -0,0 +1,7 @@
module.exports = (schema) => (req, _res, next) => {
try {
req.validated = schema.parse({ body: req.body, params: req.params, query: req.query });
req.body = req.validated.body;
next();
} catch (error) { next(error); }
};
+3
View File
@@ -39,6 +39,8 @@ db.sequelize = sequelize;
db.User = require("./user/user.model")(sequelize, DataTypes);
db.Customer = require("./user/customer.model")(sequelize, DataTypes);
db.BusinessCustomer = require("./user/businessCustomer.model")(sequelize, DataTypes);
db.AuthSession = require("./user/authSession.model")(sequelize, DataTypes);
db.UserIdentity = require("./user/userIdentity.model")(sequelize, DataTypes);
db.UserActivity = require("./activities/userActivities.model")(sequelize, DataTypes);
db.Profile = require("./user/profile.model")(sequelize, DataTypes);
@@ -50,6 +52,7 @@ db.roles = require("./permission/role.model")(sequelize, DataTypes);
db.permission = require("./permission/permission.model")(sequelize, DataTypes);
db.rolePermission = require("./permission/rolePermission.model")(sequelize, DataTypes);
db.userPermission = require("./permission/userPermission.model")(sequelize, DataTypes);
db.UserRole = require("./permission/userRole.model")(sequelize, DataTypes);
// Document Management
db.Document = require("./document/document.model")(sequelize, DataTypes);
+3 -1
View File
@@ -19,7 +19,8 @@ module.exports = (sequelize, DataTypes) => {
},
roleName: {
type: DataTypes.STRING,
allowNull: false
allowNull: false,
unique: true
},
roleDescription: {
type: DataTypes.TEXT,
@@ -37,6 +38,7 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "role_id",
as: "rolePermissions"
});
roles.hasMany(db.UserRole, { foreignKey: "role_id", as: "userRoles" });
};
return roles;
+21 -1
View File
@@ -28,7 +28,27 @@ module.exports = (sequelize, DataTypes) => {
},
{
tableName: "rolePermission",
timestamps: true
timestamps: true,
indexes: [{ unique: true, fields: ["role_id", "permission_id"] }],
hooks: {
afterCreate: async (record) => {
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
},
afterDestroy: async (record) => {
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
},
afterUpdate: async (record) => {
const users = await sequelize.models.UserRole.findAll({ where: { role_id: record.role_id } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
},
afterBulkCreate: async (records) => {
const roleIds = [...new Set(records.map((record) => record.role_id))];
const users = await sequelize.models.UserRole.findAll({ where: { role_id: roleIds } });
await Promise.all(users.map((item) => require("../../utils/cache.util").clearPermissionCache(item.user_id)));
}
}
}
);
@@ -29,7 +29,14 @@ module.exports = (sequelize, DataTypes) => {
},
{
tableName: "userPermission",
timestamps: true
timestamps: true,
indexes: [{ unique: true, fields: ["user_id", "permission_id"] }],
hooks: {
afterCreate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
afterUpdate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
afterDestroy: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id)
,afterBulkCreate: (records) => Promise.all(records.map((record) => require("../../utils/cache.util").clearPermissionCache(record.user_id)))
}
}
);
+15
View File
@@ -0,0 +1,15 @@
module.exports = (sequelize, DataTypes) => {
const UserRole = sequelize.define("UserRole", {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
user_id: { type: DataTypes.STRING, allowNull: false },
role_id: { type: DataTypes.STRING, allowNull: false },
}, { tableName: "user_roles", timestamps: true, indexes: [{ unique: true, fields: ["user_id", "role_id"] }], hooks: {
afterCreate: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
afterDestroy: (record) => require("../../utils/cache.util").clearPermissionCache(record.user_id),
} });
UserRole.associate = (db) => {
UserRole.belongsTo(db.User, { foreignKey: "user_id", as: "user" });
UserRole.belongsTo(db.roles, { foreignKey: "role_id", as: "role" });
};
return UserRole;
};
+22
View File
@@ -0,0 +1,22 @@
module.exports = (sequelize, DataTypes) => {
const AuthSession = sequelize.define("AuthSession", {
id: { type: DataTypes.UUID, primaryKey: true },
user_id: { type: DataTypes.STRING, allowNull: false },
token_family_id: { type: DataTypes.UUID, allowNull: false },
refresh_token_hash: { type: DataTypes.STRING(64), allowNull: false },
device_name: DataTypes.STRING,
user_agent: DataTypes.STRING(500),
ip_address: DataTypes.STRING(64),
remember_me: { type: DataTypes.BOOLEAN, allowNull: false, defaultValue: false },
token_version: { type: DataTypes.INTEGER, allowNull: false },
last_used_at: DataTypes.DATE,
expires_at: { type: DataTypes.DATE, allowNull: false },
revoked_at: DataTypes.DATE,
revoked_reason: DataTypes.STRING,
replaced_by_session_id: DataTypes.UUID,
}, { tableName: "auth_sessions", timestamps: true, indexes: [
{ fields: ["user_id"] }, { fields: ["token_family_id"] }, { fields: ["expires_at"] },
] });
AuthSession.associate = (db) => AuthSession.belongsTo(db.User, { foreignKey: "user_id", as: "user" });
return AuthSession;
};
+7 -2
View File
@@ -33,10 +33,10 @@ module.exports = (sequelize, DataTypes) => {
},
password: {
type: DataTypes.STRING,
allowNull: false,
allowNull: true,
},
accountType: {
type: DataTypes.ENUM("business_customer", "customer"),
type: DataTypes.ENUM("superadmin", "admin", "manager", "business_customer", "rider", "customer", "support_agent"),
defaultValue: "customer",
},
accountStatus: {
@@ -59,6 +59,8 @@ module.exports = (sequelize, DataTypes) => {
allowNull: true,
defaultValue: null,
},
tokenVersion: { type: DataTypes.INTEGER, allowNull: false, defaultValue: 0 },
lastLoginAt: { type: DataTypes.DATE, allowNull: true },
},
{
tableName: "users",
@@ -83,6 +85,9 @@ module.exports = (sequelize, DataTypes) => {
foreignKey: "user_id",
as: "businessCustomer",
});
User.hasMany(db.AuthSession, { foreignKey: "user_id", as: "authSessions" });
User.hasMany(db.UserIdentity, { foreignKey: "user_id", as: "identities" });
User.hasMany(db.UserRole, { foreignKey: "user_id", as: "userRoles" });
};
return User;
+13
View File
@@ -0,0 +1,13 @@
module.exports = (sequelize, DataTypes) => {
const UserIdentity = sequelize.define("UserIdentity", {
id: { type: DataTypes.UUID, primaryKey: true },
user_id: { type: DataTypes.STRING, allowNull: false },
provider: { type: DataTypes.ENUM("google", "apple"), allowNull: false },
provider_subject: { type: DataTypes.STRING, allowNull: false },
provider_email: DataTypes.STRING,
}, { tableName: "user_identities", timestamps: true, indexes: [
{ unique: true, fields: ["provider", "provider_subject"] }, { fields: ["user_id"] },
] });
UserIdentity.associate = (db) => UserIdentity.belongsTo(db.User, { foreignKey: "user_id", as: "user" });
return UserIdentity;
};
+10
View File
@@ -0,0 +1,10 @@
const express = require("express");
const auth = require("../controllers/auth.controller");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const router = express.Router();
router.use(sensitiveLimiter);
router.post("/login", validate(schemas.login), auth.adminLogin);
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
module.exports = router;
+9
View File
@@ -0,0 +1,9 @@
const express = require("express");
const controller = require("../controllers/adminUser.controller");
const { authenticate } = require("../middleware/auth.middleware");
const { authorizedAccountType } = require("../middleware/permission.middleware");
const router = express.Router();
router.use(authenticate, authorizedAccountType(["superadmin"]));
router.patch("/:id/status", controller.updateStatus);
router.patch("/:id/account-type", controller.updateAccountType);
module.exports = router;
+22 -31
View File
@@ -1,36 +1,27 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
const express = require("express");
const auth = require("../controllers/auth.controller");
const user = require("../controllers/user.controller");
const { authenticate } = require("../middleware/auth.middleware");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
// app/routes/auth.routes.js
const express = require('express');
const router = express.Router();
const authController = require('../controllers/auth.controller');
const {authenticate} = require('../middleware/auth.middleware');
const { sensitiveLimiter } = require('../middleware/rateLimit.middleware');
router.use(sensitiveLimiter);
// GET /api/auth/me
router.get("/me", authenticate, (req, res) => {
res.json({
authenticated: true,
user: req.user
});
});
router.post('/req-otp', authController.loginReq);
router.post('/login', authController.login);
router.post('/refresh', authController.refreshToken);
router.post('/forgot-password', authController.forgotPassword);
router.post('/reset-password', authController.resetPassword);
router.post('/change-password', authenticate, authController.changePassword);
router.post('/logout', authController.logout);
router.post("/register", validate(schemas.register), user.createNewUser);
router.post("/login", validate(schemas.login), auth.login);
router.post("/req-otp", validate(schemas.login), auth.loginReq);
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
router.post("/refresh", validate(schemas.refresh), auth.refreshToken);
router.post("/logout", auth.logout);
router.post("/logout-all", authenticate, auth.logoutAll);
router.post("/forgot-password", validate(schemas.forgot), auth.forgotPassword);
router.post("/reset-password", validate(schemas.reset), auth.resetPassword);
router.post("/change-password", authenticate, validate(schemas.change), auth.changePassword);
router.post("/verify-email", validate(schemas.verifyEmail), auth.verifyEmail);
router.post("/resend-verification", validate(schemas.resend), auth.resendVerification);
router.post("/google", validate(schemas.oauth), auth.oauth("google"));
router.post("/apple", validate(schemas.oauth), auth.oauth("apple"));
router.get("/me", authenticate, auth.me);
module.exports = router;
+8 -1
View File
@@ -20,6 +20,9 @@ const docsRoutes = require("./docs.routes");
const permissionRoutes = require("./permission.routes");
const profileRoutes = require("./profile.routes");
const notificationRoutes = require("./notification.routes");
const adminAuthRoutes = require("./adminAuth.routes");
const riderAuthRoutes = require("./riderAuth.routes");
const adminUserRoutes = require("./adminUser.routes");
const router = express.Router();
@@ -31,5 +34,9 @@ router.use("/document", documentRoutes);
router.use("/docs", docsRoutes);
router.use("/profile", profileRoutes);
router.use("/notification", notificationRoutes);
router.use("/permissions", permissionRoutes);
router.use("/admin/auth", adminAuthRoutes);
router.use("/rider/auth", riderAuthRoutes);
router.use("/admin/users", adminUserRoutes);
module.exports = router;
module.exports = router;
+7
View File
@@ -14,6 +14,13 @@ const router = express.Router();
const permissionController = require("../controllers/permission.controller");
const { authenticate } = require("../middleware/auth.middleware");
const { authorizedAccountType } = require("../middleware/permission.middleware");
const roleAssignmentController = require("../controllers/roleAssignment.controller");
router.use(authenticate, authorizedAccountType(["superadmin"]));
router.post("/users/:userId/roles/:roleId", roleAssignmentController.assignRole);
router.delete("/users/:userId/roles/:roleId", roleAssignmentController.removeRole);
router.get("/roles", permissionController.getAllRoles);
router.get("/roles/:roleId", permissionController.getRoleById);
// Permission routes
router.post(
+10 -6
View File
@@ -12,6 +12,7 @@
const express = require("express");
const router = express.Router();
const profileController = require("../controllers/profile.controller.js");
const authController = require("../controllers/auth.controller.js");
const { authenticate } = require("../middleware/auth.middleware");
const {
@@ -20,29 +21,32 @@ const {
} = require("../middleware/permission.middleware");
const PERMISSIONS = require("../constants/permissions");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { requireOwnership } = require("../middleware/permission.middleware");
router.post("/req-reset-password", sensitiveLimiter, profileController.requestPasswordReset);
router.post("/req-reset-password", sensitiveLimiter, validate(schemas.forgot), authController.forgotPassword);
router.post("/reset-password", sensitiveLimiter, profileController.resetPassword);
router.post("/reset-password", sensitiveLimiter, validate(schemas.reset), authController.resetPassword);
router.post(
"/change-password",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
profileController.changePassword,
validate(schemas.change),
authController.changePassword,
);
router.get(
"/avatar/:userId",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
requireOwnership("userId"),
profileController.getProfileAvatar,
);
router.get(
"/background/:userId",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
requireOwnership("userId"),
profileController.getProfileBackgroundImage,
);
+10
View File
@@ -0,0 +1,10 @@
const express = require("express");
const auth = require("../controllers/auth.controller");
const validate = require("../middleware/validate.middleware");
const schemas = require("../validation/auth.schemas");
const { sensitiveLimiter } = require("../middleware/rateLimit.middleware");
const router = express.Router();
router.use(sensitiveLimiter);
router.post("/login", validate(schemas.login), auth.riderLogin);
router.post("/verify-otp", validate(schemas.verifyOtp), auth.verifyOtp);
module.exports = router;
+6 -3
View File
@@ -19,6 +19,9 @@ const {
const { authorizedAccountType, checkPermission } = require("../middleware/permission.middleware");
const PERMISSIONS = require("../constants/permissions");
router.get("/me", authenticate, userController.getCurrentUser);
router.patch("/me", authenticate, userController.updateCurrentUser);
router.post(
"/",
@@ -41,7 +44,7 @@ router.get(
router.get(
"/",
authenticate,
authorizedAccountType(["admin"]),
authorizedAccountType(["admin", "superadmin"]),
userController.getAllUsers
);
@@ -55,14 +58,14 @@ router.get(
router.patch(
"/:id",
authenticate,
authorizedAccountType(["admin", "management", "team_head", "user"]),
authorizedAccountType(["superadmin"]),
userController.updateUser
);
router.delete(
"/:id",
authenticate,
authorizedAccountType(["admin"]),
authorizedAccountType(["superadmin"]),
userController.deleteUser
);
+65
View File
@@ -0,0 +1,65 @@
const crypto = require("crypto");
const db = require("../../models");
const { checkPassword } = require("../../utils/hashPassword.util");
const { generateToken } = require("../../utils/jwt.util");
const { generateUserId, generateId } = require("../../utils/idGen.util");
const { ACCOUNT_TYPES, PRIVILEGED_ACCOUNT_TYPES } = require("../../constants/accountTypes");
const { createLoginChallenge, verifyLoginChallenge } = require("./otp.service");
const { createSession, rotateSession } = require("./session.service");
const { sendLoginOtp } = require("./email.service");
const oauth = require("./oauth.service");
const authError = (code = "INVALID_CREDENTIALS", status = 401) => Object.assign(new Error(code === "ACCOUNT_NOT_ACTIVE" ? "Account is not active" : "Authentication failed"), { code, status });
const contextFromRequest = (req, deviceName) => ({ deviceName, userAgent: req.get("user-agent")?.slice(0, 500), ipAddress: req.ip });
const tokenPair = (user, session, refreshToken) => ({ accessToken: generateToken({ userId: user.id, sessionId: session.id, tokenVersion: user.tokenVersion }), refreshToken, refreshExpiresAt: session.expires_at });
const beginPasswordLogin = async ({ email, password, rememberMe, deviceName }, req, allowedTypes) => {
const user = await db.User.findOne({ where: { email: email.toLowerCase() } });
const valid = user?.password && await checkPassword(password, user.password);
if (!user || !valid || (allowedTypes && !allowedTypes.includes(user.accountType))) throw authError();
if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const context = contextFromRequest(req, deviceName);
const challenge = await createLoginChallenge({ userId: user.id, rememberMe, context });
await sendLoginOtp(user, challenge.otp);
return { challengeId: challenge.challengeId };
};
const completeOtpLogin = async ({ challengeId, otp }, req) => {
const challenge = await verifyLoginChallenge(challengeId, otp);
return db.sequelize.transaction(async (transaction) => {
const user = await db.User.findByPk(challenge.userId, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const created = await createSession({ user, rememberMe: challenge.rememberMe, ...challenge.context, ...contextFromRequest(req, challenge.context.deviceName), transaction });
user.lastLoginAt = new Date(); await user.save({ transaction });
return { user, ...tokenPair(user, created.session, created.refreshToken) };
});
};
const refresh = async (refreshToken, req) => {
const rotated = await rotateSession(refreshToken, contextFromRequest(req));
return { user: rotated.user, ...tokenPair(rotated.user, rotated.session, rotated.refreshToken) };
};
const authenticateOAuth = async (provider, input, req) => {
const verified = provider === "google" ? await oauth.verifyGoogleToken(input.idToken) : await oauth.verifyAppleToken(input.idToken);
return db.sequelize.transaction(async (transaction) => {
let identity = await db.UserIdentity.findOne({ where: { provider, provider_subject: verified.subject }, transaction, lock: transaction.LOCK.UPDATE });
let user = identity && await db.User.findByPk(identity.user_id, { transaction });
if (!user) {
if (!verified.email || !verified.emailVerified) throw authError("OAUTH_LINK_REQUIRED", 403);
user = await db.User.findOne({ where: { email: verified.email }, transaction, lock: transaction.LOCK.UPDATE });
if (user && (PRIVILEGED_ACCOUNT_TYPES.includes(user.accountType) || user.accountType === ACCOUNT_TYPES.RIDER)) throw authError("OAUTH_LINK_REQUIRED", 403);
if (!user) {
user = await db.User.create({ id: generateUserId(), firstName: verified.firstName || input.firstName || "ZUMRI", lastName: verified.lastName || input.lastName || "Customer", email: verified.email, password: null, accountType: ACCOUNT_TYPES.CUSTOMER, accountStatus: "ACTIVE", emailVerifiedAt: new Date(), tokenVersion: 0 }, { transaction });
await db.Profile.create({ profile_id: generateId(), user_id: user.id, theme: "light", notificationsEnabled: true }, { transaction });
}
identity = await db.UserIdentity.create({ id: crypto.randomUUID(), user_id: user.id, provider, provider_subject: verified.subject, provider_email: verified.email }, { transaction });
}
if (user.accountStatus !== "ACTIVE") throw authError("ACCOUNT_NOT_ACTIVE", 403);
const created = await createSession({ user, rememberMe: input.rememberMe, ...contextFromRequest(req, input.deviceName), transaction });
user.lastLoginAt = new Date(); await user.save({ transaction });
return { user, identity, ...tokenPair(user, created.session, created.refreshToken) };
});
};
module.exports = { beginPasswordLogin, completeOtpLogin, refresh, authenticateOAuth, contextFromRequest, tokenPair };
+6
View File
@@ -0,0 +1,6 @@
const { sendMail } = require("../../utils/mail.util");
const sendLoginOtp = (user, otp) => sendMail({ to: user.email, subject: "Your ZUMRI login code", templateName: "otp", templateVars: { firstName: user.firstName, otp }, text: `Your ZUMRI login code is ${otp}.` });
const sendPasswordChanged = (user) => sendMail({ to: user.email, subject: "Your ZUMRI password was changed", templateName: "passwordChanged", templateVars: { customer_name: user.firstName, changed_at: new Date().toLocaleString() }, text: "Your ZUMRI password was changed. Contact support if this was not you." });
module.exports = { sendLoginOtp, sendPasswordChanged };
+31
View File
@@ -0,0 +1,31 @@
const crypto = require("crypto");
const jwt = require("jsonwebtoken");
const { OAuth2Client } = require("google-auth-library");
let appleKeys;
let appleKeysAt = 0;
const verifyGoogleToken = async (idToken) => {
if (!process.env.GOOGLE_CLIENT_ID) throw Object.assign(new Error("Google authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" });
const ticket = await new OAuth2Client(process.env.GOOGLE_CLIENT_ID).verifyIdToken({ idToken, audience: process.env.GOOGLE_CLIENT_ID });
const payload = ticket.getPayload();
if (!payload?.sub || !payload.email || payload.email_verified !== true) throw new Error("Invalid Google identity token");
return { subject: payload.sub, email: payload.email.toLowerCase(), emailVerified: true, firstName: payload.given_name, lastName: payload.family_name };
};
const getAppleKeys = async () => {
if (appleKeys && Date.now() - appleKeysAt < 3600000) return appleKeys;
const response = await fetch("https://appleid.apple.com/auth/keys");
if (!response.ok) throw new Error("Apple key service unavailable");
appleKeys = (await response.json()).keys; appleKeysAt = Date.now(); return appleKeys;
};
const verifyAppleToken = async (idToken) => {
if (!process.env.APPLE_CLIENT_ID) throw Object.assign(new Error("Apple authentication is unavailable"), { status: 503, code: "OAUTH_UNAVAILABLE" });
const decoded = jwt.decode(idToken, { complete: true });
const key = (await getAppleKeys()).find((candidate) => candidate.kid === decoded?.header?.kid && candidate.alg === "RS256");
if (!key) throw new Error("Invalid Apple identity token");
const payload = jwt.verify(idToken, crypto.createPublicKey({ key, format: "jwk" }), { algorithms: ["RS256"], issuer: "https://appleid.apple.com", audience: process.env.APPLE_CLIENT_ID });
if (!payload.sub) throw new Error("Invalid Apple identity token");
return { subject: payload.sub, email: payload.email?.toLowerCase(), emailVerified: payload.email_verified === true || payload.email_verified === "true" };
};
module.exports = { verifyGoogleToken, verifyAppleToken };
+29
View File
@@ -0,0 +1,29 @@
const crypto = require("crypto");
const redis = require("../../config/redisClient");
const otpHash = (challengeId, otp) => crypto.createHmac("sha256", process.env.JWT_SECRET).update(`${challengeId}:${otp}`).digest("hex");
const generateOtp = () => crypto.randomInt(0, 1000000).toString().padStart(6, "0");
const createLoginChallenge = async ({ userId, rememberMe, context }) => {
const challengeId = crypto.randomUUID();
const otp = generateOtp();
await redis.set(`login:${challengeId}`, JSON.stringify({ userId, otpHash: otpHash(challengeId, otp), attempts: 0, rememberMe, context }), "EX", Number(process.env.LOGIN_OTP_TTL_SECONDS || 900));
return { challengeId, otp };
};
const VERIFY_SCRIPT = `
local raw=redis.call('GET',KEYS[1]); if not raw then return {-3} end
local value=cjson.decode(raw)
if value.otpHash==ARGV[1] then redis.call('DEL',KEYS[1]); return {1,value.userId,cjson.encode(value)} end
value.attempts=(value.attempts or 0)+1
if value.attempts>=tonumber(ARGV[2]) then redis.call('DEL',KEYS[1]); return {-2} end
redis.call('SET',KEYS[1],cjson.encode(value),'KEEPTTL'); return {-1}
`;
const verifyLoginChallenge = async (challengeId, otp) => {
const result = await redis.eval(VERIFY_SCRIPT, 1, `login:${challengeId}`, otpHash(challengeId, otp), Number(process.env.LOGIN_OTP_MAX_ATTEMPTS || 5));
if (Number(result[0]) !== 1) throw Object.assign(new Error("Invalid or expired challenge"), { code: "INVALID_OTP", status: 401 });
const stored = JSON.parse(result[2]);
return { userId: result[1], rememberMe: Boolean(stored.rememberMe), context: stored.context || {} };
};
module.exports = { generateOtp, otpHash, createLoginChallenge, verifyLoginChallenge };
+59
View File
@@ -0,0 +1,59 @@
const crypto = require("crypto");
const db = require("../../models");
const digest = (token) => crypto.createHash("sha256").update(token).digest("hex");
const safeEqual = (left, right) => left?.length === right?.length && crypto.timingSafeEqual(Buffer.from(left), Buffer.from(right));
const rawToken = (id) => `${id}.${crypto.randomBytes(48).toString("base64url")}`;
const tokenId = (token) => typeof token === "string" ? token.split(".", 1)[0] : null;
const ttlDays = (rememberMe) => Number(process.env[rememberMe ? "REMEMBER_ME_REFRESH_TOKEN_TTL_DAYS" : "REFRESH_TOKEN_TTL_DAYS"] || (rememberMe ? 30 : 7));
const createSession = async ({ user, rememberMe = false, deviceName, userAgent, ipAddress, familyId, transaction }) => {
const id = crypto.randomUUID();
const refreshToken = rawToken(id);
const expiresAt = new Date(Date.now() + ttlDays(rememberMe) * 86400000);
const session = await db.AuthSession.create({
id, user_id: user.id, token_family_id: familyId || crypto.randomUUID(), refresh_token_hash: digest(refreshToken),
device_name: deviceName, user_agent: userAgent, ip_address: ipAddress, remember_me: rememberMe,
token_version: user.tokenVersion, last_used_at: new Date(), expires_at: expiresAt,
}, { transaction });
return { session, refreshToken, expiresAt };
};
const revokeFamily = async (familyId, reason, transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason },
{ where: { token_family_id: familyId, revoked_at: null }, transaction },
);
const rotateSession = async (token, context = {}) => db.sequelize.transaction(async (transaction) => {
const id = tokenId(token);
if (!id) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
const session = await db.AuthSession.findByPk(id, { transaction, lock: transaction.LOCK.UPDATE });
if (!session) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
if (!safeEqual(digest(token), session.refresh_token_hash)) throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
if (session.revoked_at) {
if (session.revoked_reason === "ROTATED") await revokeFamily(session.token_family_id, "REFRESH_TOKEN_REUSE", transaction);
throw Object.assign(new Error("Invalid session"), { code: session.revoked_reason === "ROTATED" ? "REFRESH_TOKEN_REUSE" : "INVALID_SESSION", status: 401 });
}
if (session.expires_at <= new Date()) {
session.revoked_at = new Date(); session.revoked_reason = "EXPIRED"; await session.save({ transaction });
throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
}
const user = await db.User.findByPk(session.user_id, { transaction, lock: transaction.LOCK.UPDATE });
if (!user || user.accountStatus !== "ACTIVE" || user.tokenVersion !== session.token_version) {
await revokeFamily(session.token_family_id, "ACCOUNT_OR_TOKEN_VERSION_INVALID", transaction);
throw Object.assign(new Error("Invalid session"), { code: "INVALID_SESSION", status: 401 });
}
const replacement = await createSession({ user, rememberMe: session.remember_me, familyId: session.token_family_id, ...context, transaction });
session.revoked_at = new Date(); session.revoked_reason = "ROTATED"; session.replaced_by_session_id = replacement.session.id;
session.last_used_at = new Date(); await session.save({ transaction });
return { ...replacement, user };
});
const revokeSession = async (id, reason = "LOGOUT", transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason }, { where: { id, revoked_at: null }, transaction },
);
const revokeAllUserSessions = async (userId, reason, transaction) => db.AuthSession.update(
{ revoked_at: new Date(), revoked_reason: reason }, { where: { user_id: userId, revoked_at: null }, transaction },
);
module.exports = { createSession, rotateSession, revokeSession, revokeFamily, revokeAllUserSessions, hashRefreshToken: digest, tokenId };
+6 -4
View File
@@ -12,6 +12,7 @@
const db = require("../models");
const User = db.User;
const RolePermission = db.rolePermission;
const UserRole = db.UserRole;
const UserPermission = db.userPermission;
const {
@@ -32,10 +33,11 @@ const getEffectivePermissions = async (userId) => {
const user = await User.findByPk(userId);
if (!user) return [];
const rolePermissions = await RolePermission.findAll({
where: { role_id: user.roleID },
const roles = await UserRole.findAll({ where: { user_id: userId }, attributes: ["role_id"] });
const rolePermissions = roles.length ? await RolePermission.findAll({
where: { role_id: roles.map((role) => role.role_id) },
attributes: ["permission_id"]
});
}) : [];
const userPermissions = await UserPermission.findAll({
where: { user_id: userId },
@@ -68,4 +70,4 @@ const getEffectivePermissions = async (userId) => {
}
};
module.exports = { getEffectivePermissions };
module.exports = { getEffectivePermissions };
+15 -1
View File
@@ -16,6 +16,8 @@ const hashEmailVerificationToken = (token) => {
};
const createEmailVerification = async (userId) => {
const previousKey = await redis.get(`email-verification-user:${userId}`);
if (previousKey) await redis.del(previousKey);
// 1. Generate raw token
const token = generateEmailVerificationToken();
@@ -26,6 +28,7 @@ const createEmailVerification = async (userId) => {
const redisKey = `email-verification:${tokenHash}`;
await redis.set(redisKey, userId, "EX", EMAIL_VERIFICATION_TTL);
await redis.set(`email-verification-user:${userId}`, redisKey, "EX", EMAIL_VERIFICATION_TTL);
return token;
};
@@ -45,12 +48,14 @@ const verifyEmailVerificationToken = async (token) => {
const redisKey = `email-verification:${tokenHash}`;
// 3. Search Redis
const userId = await redis.get(redisKey);
const userId = await redis.getdel(redisKey);
if (!userId) {
return null;
}
await redis.del(`email-verification-user:${userId}`);
return {
userId,
redisKey,
@@ -82,10 +87,19 @@ const deleteEmailVerification = async (redisKey) => {
await redis.del(redisKey);
};
const consumeEmailVerificationToken = async (token) => {
if (!token || typeof token !== "string") return null;
const redisKey = `email-verification:${hashEmailVerificationToken(token)}`;
const userId = await redis.getdel(redisKey);
if (userId) await redis.del(`email-verification-user:${userId}`);
return userId;
};
module.exports = {
createEmailVerification,
verifyEmailVerificationToken,
sendVerificationEmail,
deleteEmailVerification,
consumeEmailVerificationToken,
hashEmailVerificationToken,
};
+4 -3
View File
@@ -10,6 +10,7 @@
// app/utils/idGen.util.js
const { v4: uuidv4 } = require("uuid");
const crypto = require("crypto");
const { nextSequence } = require("./referenceNumber.util");
const {log} = require("./consoleLog.utill");
@@ -20,15 +21,15 @@ const { generateDeliveryNote } = require("./id/dispatchNote.utill");
const generateUserId = () => {
return "usr_" + Math.random().toString(36).slice(2, 10);
return "usr_" + crypto.randomUUID();
};
const generateCustomerId = () => {
return "cust_" + Math.random().toString(36).slice(2, 10);
return "cust_" + crypto.randomUUID();
}
const generateBusinessCustomerId = () => {
return "b_cust_" + Math.random().toString(36).slice(2, 10);
return "b_cust_" + crypto.randomUUID();
}
const generateClientId = () => {
+20 -45
View File
@@ -1,51 +1,26 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/utils/jwt.util.js
const jwt = require("jsonwebtoken");
require("dotenv").config();
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "15m"; // token validity
const REFRESH_TOKEN_DAYS = process.env.REFRESH_TOKEN_DAYS || "7d"; // refresh token validity
const getSecret = (name) => {
const value = process.env[name];
if (!value || value.length < 32) throw new Error(`${name} is not configured securely`);
return value;
const secret = () => {
if (!process.env.JWT_SECRET || process.env.JWT_SECRET.length < 32) throw new Error("JWT_SECRET is not configured securely");
return process.env.JWT_SECRET;
};
/**
* Generate JWT token
* @param {Object} payload - usually { id, email, role }
* @returns string
*/
const generateToken = (payload) => {
return jwt.sign(payload, getSecret("JWT_SECRET"), { expiresIn: JWT_EXPIRES_IN });
};
const generateToken = ({ userId, sessionId, tokenVersion }) => jwt.sign(
{ sid: sessionId, tokenVersion },
secret(),
{
algorithm: "HS256",
subject: userId,
issuer: process.env.JWT_ISSUER || "zumri-api",
audience: process.env.JWT_AUDIENCE || "zumri-clients",
expiresIn: process.env.ACCESS_TOKEN_TTL || "15m",
},
);
/**
* Verify JWT token
* @param {string} token
* @returns payload or throws error
*/
const verifyToken = (token) => {
return jwt.verify(token, getSecret("JWT_SECRET"));
};
const verifyToken = (token) => jwt.verify(token, secret(), {
algorithms: ["HS256"],
issuer: process.env.JWT_ISSUER || "zumri-api",
audience: process.env.JWT_AUDIENCE || "zumri-clients",
});
const generateRefreshToken = (payload) => {
return jwt.sign(payload, getSecret("REFRESH_TOKEN_SECRET"), { expiresIn: REFRESH_TOKEN_DAYS });
}
const verifyRefreshToken = (token) => {
return jwt.verify(token, getSecret("REFRESH_TOKEN_SECRET"));
}
module.exports = { generateToken, verifyToken, generateRefreshToken, verifyRefreshToken };
module.exports = { generateToken, verifyToken };
-39
View File
@@ -1,39 +0,0 @@
/**
* Copyright (c) 2026 Niolla
* All rights reserved.
*
* This source code is proprietary and confidential.
* Unauthorized copying, modification, distribution, or use
* of this file, via any medium, is strictly prohibited.
*/
// app/utils/otp.util.js
const otpCache = new Map();
function generateOTP(key){
const otp = Math.floor(100000 + Math.random() * 900000).toString();
saveOTP(key, otp);
return otp;
}
function saveOTP(key, otp) {
ttl = parseInt(process.env.LOGIN_OTP_TTL_SECONDS || 300);
const expiresAt = Date.now() + ttl * 1000; // Convert seconds to milliseconds
otpCache.set(key, { otp, expiresAt });
}
// Validate OTP
function validateOTP(key, otp) {
const record = otpCache.get(key);
if (!record) return false;
if (Date.now() > record.expiresAt) {
otpCache.delete(key);
return false;
}
const isValid = record.otp === otp;
if (isValid) otpCache.delete(key); // OTP can be used only once
return isValid;
}
module.exports = { generateOTP, validateOTP };
+6
View File
@@ -69,6 +69,11 @@ const deletePasswordReset = async (redisKey) => {
await redis.del(redisKey);
};
const consumePasswordResetToken = async (token) => {
if (!token || typeof token !== "string") return null;
return redis.getdel(`password-reset:${hashPasswordResetToken(token)}`);
};
const sendPasswordResetEmail =
async (email, firstName, resetToken) => {
@@ -130,6 +135,7 @@ module.exports = {
createPasswordReset,
verifyPasswordResetToken,
deletePasswordReset,
consumePasswordResetToken,
hashPasswordResetToken,
sendPasswordResetEmail,
sendPasswordChangedEmail,
-201
View File
@@ -1,201 +0,0 @@
// app/utils/refreshSession.util.js
const crypto = require("crypto");
const {
generateRefreshToken,
verifyRefreshToken,
} = require("./jwt.util");
const refreshSessions = new Map();
// Default = 7 days
const REFRESH_SESSION_TTL =
7 * 24 * 60 * 60 * 1000;
const hashRefreshToken = (token) => {
return crypto
.createHash("sha256")
.update(token)
.digest("hex");
};
const createRefreshSession = (userId) => {
// Unique session ID
const sessionId =
crypto.randomUUID();
// Create raw Refresh JWT
const refreshToken =
generateRefreshToken({
sub: userId,
sid: sessionId,
});
// Hash raw refresh token
const tokenHash =
hashRefreshToken(
refreshToken
);
// Expiration time
const expiresAt =
Date.now() +
REFRESH_SESSION_TTL;
// Save only HASH in RAM
refreshSessions.set(
sessionId,
{
userId,
tokenHash,
expiresAt,
}
);
return {
refreshToken,
sessionId,
};
};
const validateRefreshSession = (
refreshToken
) => {
if (!refreshToken) {
return null;
}
let decoded;
try {
decoded =
verifyRefreshToken(
refreshToken
);
} catch (error) {
return null;
}
const sessionId =
decoded.sid;
const userId =
decoded.sub;
if (!sessionId || !userId) {
return null;
}
// Get session from RAM
const session =
refreshSessions.get(
sessionId
);
if (!session) {
return null;
}
// Check session expiration
if (
Date.now() >
session.expiresAt
) {
refreshSessions.delete(
sessionId
);
return null;
}
// Hash received refresh token
const receivedHash =
hashRefreshToken(
refreshToken
);
// Compare stored hash
if (
receivedHash !==
session.tokenHash
) {
return null;
}
// Extra user check
if (
session.userId !==
userId
) {
return null;
}
return {
userId,
sessionId,
};
};
const deleteRefreshSession = (
sessionId
) => {
refreshSessions.delete(
sessionId
);
};
const deleteAllUserSessions = (
userId
) => {
for (
const [sessionId, session]
of refreshSessions.entries()
) {
if (
session.userId === userId
) {
refreshSessions.delete(
sessionId
);
}
}
};
module.exports = {
createRefreshSession,
validateRefreshSession,
deleteRefreshSession,
deleteAllUserSessions,
};
+3 -30
View File
@@ -1,32 +1,5 @@
//app/utils/validation/validatePassword.util.js
const validatePassword = (password) => {
// Check if password is a string
if (typeof password !== "string") {
return false;
}
const { passwordSchema } = require("../../validation/auth.schemas");
// At least 1 uppercase letter
const hasUppercase = /[A-Z]/.test(password);
const validatePassword = (password) => passwordSchema.safeParse(password).success;
// At least 1 lowercase letter
const hasLowercase = /[a-z]/.test(password);
// At least 1 symbol
const hasSymbol = /[^A-Za-z0-9]/.test(password);
// At least 4 numbers
const numberCount = (password.match(/[0-9]/g) || []).length;
const hasFourNumbers = numberCount >= 4;
return (
hasUppercase &&
hasLowercase &&
hasSymbol &&
hasFourNumbers
);
};
module.exports = {
validatePassword,
};
module.exports = { validatePassword };
+22
View File
@@ -0,0 +1,22 @@
const { z } = require("zod");
const email = z.string().trim().toLowerCase().email();
const password = z.string().min(12).superRefine((value, context) => {
const failures = [!/[A-Z]/.test(value), !/[a-z]/.test(value), !/[^A-Za-z0-9]/.test(value), (value.match(/\d/g) || []).length < 4];
if (failures.some(Boolean)) context.addIssue({ code: "custom", message: "Password must include uppercase, lowercase, a symbol, and at least four numbers" });
});
const body = (shape) => z.object({ body: z.object(shape).strict(), params: z.object({}).passthrough(), query: z.object({}).passthrough() });
module.exports = {
passwordSchema: password,
register: body({ firstName: z.string().trim().min(1).max(100), lastName: z.string().trim().min(1).max(100), email, password, phoneNumber: z.string().trim().min(1), address: z.string().trim().min(1), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
login: body({ email, password: z.string().min(1), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional() }),
verifyOtp: body({ challengeId: z.string().uuid(), otp: z.string().regex(/^\d{6}$/), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
refresh: body({ refreshToken: z.string().min(20).optional(), clientType: z.enum(["WEB", "MOBILE"]).default("WEB") }),
forgot: body({ email }),
reset: body({ token: z.string().min(20), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }),
change: body({ currentPassword: z.string().min(1), newPassword: password, confirmPassword: z.string() }).superRefine(({ body }, context) => { if (body.newPassword !== body.confirmPassword) context.addIssue({ code: "custom", path: ["body", "confirmPassword"], message: "Passwords do not match" }); }),
verifyEmail: body({ token: z.string().min(20) }),
resend: body({ email }),
oauth: body({ idToken: z.string().min(20), rememberMe: z.boolean().default(false), clientType: z.enum(["WEB", "MOBILE"]).default("WEB"), deviceName: z.string().max(100).optional(), firstName: z.string().max(100).optional(), lastName: z.string().max(100).optional() }),
};