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:
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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 };
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user