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