UNPKG

plugable-authentication

Version:

The PlugableAuthentication module offers middleware functions for various authentication tasks within Node.js applications, particularly in conjunction with Express.js.

1,517 lines (1,444 loc) 101 kB
const mongoose = require("mongoose"); const { v4: uuidv4 } = require("uuid"); const Joi = require("joi"); const bcrypt = require("bcryptjs"); const jwt = require("jsonwebtoken"); const { EventEmitter } = require("events"); const { isEmpty, includes } = require("lodash"); const CryptoJS = require("crypto-js"); const validator = require("validator"); const USERS_PAGE_LIMIT = 100; const EMAIL_AUTH_KEY = "email"; const OTP_AUTH_KEY = "otp"; const NEW_IP_ADDR_TOKEN_NAME = "token"; const PASSWORD_VALIDATION_PATTERN = "^[a-zA-Z0-9@$!%*?&^#~_+-]{8,256}$"; const PASSEORD_VALIDATION_NAME = '"Must be between 8 and 256 characters"'; const AUTH_KEY_VALIDATION_NAME = '"Must be valid characters"'; const MODEL_READY_EVENT = "model_ready"; const PASSWORD_HASH_SALT_LENGTH = 14; const JWT_OPTIONS = { algorithm: "HS256", noTimestamp: false, expiresIn: "1h", notBefore: "0s", }; const TOKEN_TYPE = "refresh_token"; const COOKIE_PREFIX = "pa-"; const COOKIE_EXPIRES_TIME = 7 * 24 * 60 * 60 * 1000; const EXTRA_USER_PAYLOAD_FOR_TOKEN = { scope: "user" }; const JWT_ERROR = new Set(["TokenExpiredError", "NotBeforeError"]); const TOTAL_JWT_ERROR = new Set([ "TokenExpiredError", "NotBeforeError", "JsonWebTokenError", ]); const JWT_EXPIRES_IN_TIME = "1d"; const CSRF_EXPIRES_IN_TIME = "1d"; const REFRESH_EXPIRES_IN_TIME = "7d"; const RESET_PWD_EXPIRES_IN_TIME = "2h"; const USER_SIGNUP_FAIL_DEFAULT_MESSAGE = "User signup failed. Try again later."; const USER_LOGIN_FAIL_DEFAULT_MESSAGE = "User login failed. Try again later."; const USER_IP_ADDR_ADD_FAIL_DEFAULT_MESSAGE = "IP address add failed. Try again later."; const USER_LOG_OUT_FAIL_DEFAULT_MESSAGE = "Log out failed. Try again later."; const USER_TOKEN_VERIFICATION_FAIL_DEFAULT_MESSAGE = "Sestion verification failed. Try again later."; const INVALID_TOKEN_DETAILS = "Cookie is either invalid or does not exist. Please check and try again."; const NEW_IP_ADDR_FOUND = "Looks like you tried to connect with a different IP Address."; const NEW_IP_ADDR_DURING_LOG_OUT = "Looks like you tried to log out with a IP different Address."; const NEED_AUTHENTICATION_BEFORE_USE = "You must be logged in before using this."; const INVALID_CSRF_TOKEN = "Your CSRF token is invalid."; const NEW_CSRF_TOKEN_CREATION_FAIL = "New CSRF token creation failed. Try again later."; const SESSION_EXPIRE_MESSAGE = "Your session has expired. Please log in and try again."; const NO_NEW_IP_ADDRESS = "Cannot add new IP address. Invalid value."; const RESET_PWD_TOKEN_GENERATION_FAIL = "Reset password token generation failed. Try again later."; const VERIFY_AUTH_TOKEN_GENERATION_FAIL = "Auth verification token generation failed. Try again later."; const RESET_PWD_TOKEN_VERIFICATION_FAIL = "Reset password token verification failed. Try again later."; const VERIFY_AUTH_TOKEN_VERIFICATION_FAIL = "Auth token verification failed. Try again later."; const CHANGE_PWD_FAIL_MESSAGE = "Password change failed. Try again later."; const CHANGE_AUTH_FAIL_MESSAGE = "Authentication details change failed. Try again later."; const tokenValidationType = { ipCheck: "ipCheck", resetPwd: "resetPwd", authCheck: "authCheck", }; const tokenValidationValues = Object.values(tokenValidationType); const tokenValidationValuesSet = new Set(tokenValidationValues); //===================== Model Schema ==========================// const createSchemaForCollection = ( authKeyName, disablePasswordValidation = false ) => { const UserSourceSchema = new mongoose.Schema( { browser: { type: String, required: true }, ipAddr: { type: String, required: true }, userId: { type: String, required: true }, }, { timestamps: true } ); const AuthSchema = new mongoose.Schema( { [authKeyName]: { type: String, required: true }, id: { type: String, required: true, default: uuidv4 }, refreshToken: { type: String, required: true }, csrfToken: { type: String, required: true }, isVerified: { type: Boolean, required: true, default: false }, metadata: { type: Object, default: {} }, publicData: { type: Object, default: {} }, privateData: { type: Object, default: {} }, ...(disablePasswordValidation ? {} : { password: { type: String } }), }, { timestamps: true } ); const validationTokenSchema = new mongoose.Schema( { longToken: { type: String, required: true }, userId: { type: String, required: true }, type: { type: String, required: true, enum: tokenValidationValues }, expiresIn: { type: Date }, }, { timestamps: true } ); return { authSchema: AuthSchema, userSourceSchema: UserSourceSchema, validationTokenSchema: validationTokenSchema, }; }; //================ Data validation Schema===========================// const createSchemaForDataObject = ( authKeyName, secndAuthKeyName, disableEmailValidation, disablePasswordValidation, authKeyValidationPattern, passwordValidationPattern = PASSWORD_VALIDATION_PATTERN, authKeyValidationName = AUTH_KEY_VALIDATION_NAME, passwordValidationName = PASSEORD_VALIDATION_NAME ) => { const corrctPwdValidationPattern = typeof passwordValidationPattern === "string" && passwordValidationPattern ? passwordValidationPattern : PASSWORD_VALIDATION_PATTERN; const schema = Joi.object({ [authKeyName]: disableEmailValidation ? authKeyValidationPattern && authKeyValidationName && typeof authKeyValidationPattern === "string" && typeof authKeyValidationName === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), ...(disablePasswordValidation ? secndAuthKeyName && typeof secndAuthKeyName === "string" ? { [secndAuthKeyName]: Joi.string() } : { otp: Joi.string() } : { password: Joi.string() .required() .pattern(new RegExp(corrctPwdValidationPattern), { name: passwordValidationName, }), }), metadata: Joi.object(), publicData: Joi.object(), privateData: Joi.object(), }); return schema; }; const createSchemaForChangeAuthDataObject = ( secndAuthKeyName, disableEmailValidation, disablePasswordValidation, authKeyValidationPattern, passwordValidationPattern = PASSWORD_VALIDATION_PATTERN, authKeyValidationName = AUTH_KEY_VALIDATION_NAME, passwordValidationName = PASSEORD_VALIDATION_NAME ) => { const corrctPwdValidationPattern = typeof passwordValidationPattern === "string" && passwordValidationPattern ? passwordValidationPattern : PASSWORD_VALIDATION_PATTERN; const schema = Joi.object({ oldAuth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().email().required(), newAuth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), ...(disablePasswordValidation ? secndAuthKeyName && typeof secndAuthKeyName === "string" ? { [secndAuthKeyName]: Joi.string() } : { otp: Joi.string() } : { password: Joi.string() .required() .pattern(new RegExp(corrctPwdValidationPattern), { name: passwordValidationName, }), }), }); return schema; }; const createSchemaForChangePwdDataObject = ( disableEmailValidation, authKeyValidationPattern, passwordValidationPattern = PASSWORD_VALIDATION_PATTERN, authKeyValidationName = AUTH_KEY_VALIDATION_NAME, passwordValidationName = PASSEORD_VALIDATION_NAME ) => { const corrctPwdValidationPattern = typeof passwordValidationPattern === "string" && passwordValidationPattern ? passwordValidationPattern : PASSWORD_VALIDATION_PATTERN; const schema = Joi.object({ auth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), oldPassword: Joi.string() .required() .pattern(new RegExp(corrctPwdValidationPattern), { name: passwordValidationName, }), newPassword: Joi.string() .required() .pattern(new RegExp(corrctPwdValidationPattern), { name: passwordValidationName, }), }); return schema; }; const createSchemaForResetPwdDataObject = ( disableEmailValidation, authKeyValidationPattern, authKeyValidationName = AUTH_KEY_VALIDATION_NAME ) => { const schema = Joi.object({ auth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), }); return schema; }; const createSchemaForResetPwdVerifyDataObject = ( disableEmailValidation, authKeyValidationPattern, passwordValidationPattern = PASSWORD_VALIDATION_PATTERN, authKeyValidationName = AUTH_KEY_VALIDATION_NAME, passwordValidationName = PASSEORD_VALIDATION_NAME ) => { const corrctPwdValidationPattern = typeof passwordValidationPattern === "string" && passwordValidationPattern ? passwordValidationPattern : PASSWORD_VALIDATION_PATTERN; const schema = Joi.object({ auth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), token: Joi.string().required(), newPassword: Joi.string() .required() .pattern(new RegExp(corrctPwdValidationPattern), { name: passwordValidationName, }), }); return schema; }; const createSchemaForVerifyAuthGenDataObject = ( disableEmailValidation, authKeyValidationPattern, authKeyValidationName = AUTH_KEY_VALIDATION_NAME ) => { const schema = Joi.object({ auth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), }); return schema; }; const createSchemaForVerifyAuthVerDataObject = ( disableEmailValidation, authKeyValidationPattern, authKeyValidationName = AUTH_KEY_VALIDATION_NAME ) => { const schema = Joi.object({ auth: disableEmailValidation ? authKeyValidationPattern && typeof authKeyValidationPattern === "string" ? Joi.string() .required() .pattern(new RegExp(authKeyValidationPattern), { name: authKeyValidationName, }) : Joi.string().required() : Joi.string().required().email(), token: Joi.string().required(), }); return schema; }; const createSchemaForThirdPartyLoginWithoutPwd = () => { const schema = Joi.object({ email: Joi.string().required().email(), thirdPartyProvider: Joi.string().required(), verified: Joi.boolean().required(), metadata: Joi.object(), publicData: Joi.object(), privateData: Joi.object(), }); return schema; }; const createSchemaForThirdPartyLoginWithPwd = ( passwordValidationPattern = PASSWORD_VALIDATION_PATTERN, passwordValidationName = PASSEORD_VALIDATION_NAME ) => { const corrctPwdValidationPattern = typeof passwordValidationPattern === "string" && passwordValidationPattern ? passwordValidationPattern : PASSWORD_VALIDATION_PATTERN; const schema = Joi.object({ email: Joi.string().required().email(), thirdPartyProvider: Joi.string().required(), password: Joi.string() .required() .pattern(new RegExp(corrctPwdValidationPattern), { name: passwordValidationName, }), verified: Joi.boolean().required(), metadata: Joi.object(), publicData: Joi.object(), privateData: Joi.object(), }); return schema; }; //================ Encrypt/Decrypr and Hashing ========================// const encodeToBase64 = (normalText) => Buffer.from(normalText).toString("base64"); const decodeFrmBase64 = (base64Text) => Buffer.from(base64Text, "base64").toString("utf8"); const encryptString = (plainText, secret) => { const ciphertext = CryptoJS.AES.encrypt(plainText, secret).toString(); const base64Text = encodeToBase64(ciphertext); return base64Text; }; const decryptString = (base64EncodedText, secret) => { const encryptedText = decodeFrmBase64(base64EncodedText); const plainText = CryptoJS.AES.decrypt(encryptedText, secret).toString( CryptoJS.enc.Utf8 ); return plainText; }; const createHashPasswword = ( password, saltLength = PASSWORD_HASH_SALT_LENGTH ) => { return new Promise((resolve, reject) => { bcrypt.genSalt(saltLength, function (err, salt) { if (err) { reject(err); } bcrypt.hash(password, salt, function (err, hash) { if (err) { reject(err); } resolve(encodeToBase64(hash)); }); }); }); }; const isUserPasswordSame = (plainPwd, base64HashPwd) => { const hashPwd = decodeFrmBase64(base64HashPwd); return new Promise((resolve, reject) => { bcrypt.compare(plainPwd, hashPwd, function (err, res) { if (err) { reject(err); return; } resolve(res); }); }); }; //====================== JWT Token============================// const createJwtToken = (payload, secret, jwtOptions) => { return new Promise((resolve, reject) => { jwt.sign(payload, secret, jwtOptions, (err, token) => { if (err) { reject(err); return; } resolve(token); }); }); }; const decodeJwtToken = (jwtToken, secret) => { return new Promise((resolve, reject) => { jwt.verify(jwtToken, secret, function (err, decoded) { if (err) { reject(err); return; } resolve(decoded); }); }); }; const decodeJwtTokenWithoutValidation = (token) => { return jwt.decode(token); }; const createJWTAccessAndRefreshToken = async ( payload, secret, encryptSecret, jwtOptions = {} ) => { if (!payload) { throw new Error("payload must be provided"); } if (!secret) { throw new Error("JWT secret must be provided"); } if (!encryptSecret) { throw new Error("Encryption secret must be provided"); } const jwtOptionGivenMaybe = isValidObject(jwtOptions) ? jwtOptions : {}; const accessTokenJwtOptions = Object.assign( { keyid: Date.now().toString() }, JWT_OPTIONS, jwtOptionGivenMaybe ); const refreshTokenJwtOptions = Object.assign( { keyid: Date.now().toString() }, JWT_OPTIONS, jwtOptionGivenMaybe, { expiresIn: REFRESH_EXPIRES_IN_TIME } ); const [accessToken, refreshToken] = await Promise.all([ createJwtToken(payload, secret, accessTokenJwtOptions), createJwtToken(payload, secret, refreshTokenJwtOptions), ]); const decodedValue = decodeJwtTokenWithoutValidation(accessToken); const expiresIn = decodedValue.exp - decodedValue.iat; return { refreshToken: encryptString(refreshToken, encryptSecret), accessToken, expiresIn, tokenType: TOKEN_TYPE, }; }; const createAccessFrmRefreshToken = async ( encodedRefreshToken, secret, encryptSecret, jwtOptions ) => { const userPayload = { ...EXTRA_USER_PAYLOAD_FOR_TOKEN }; try { const jwtRefreshToken = decryptString(encodedRefreshToken, encryptSecret); const decodedRefreshToken = decodeJwtTokenWithoutValidation(jwtRefreshToken); userPayload.id = decodedRefreshToken.id; await decodeJwtToken(jwtRefreshToken, secret); const jwtOptionGivenMaybe = isValidObject(jwtOptions) ? jwtOptions : {}; const accessTokenJwtOptions = Object.assign( { keyid: Date.now().toString() }, JWT_OPTIONS, jwtOptionGivenMaybe ); const accessToken = await createJwtToken( userPayload, secret, accessTokenJwtOptions ); const decodedValue = decodeJwtTokenWithoutValidation(accessToken); const expiresIn = decodedValue.exp - decodedValue.iat; return { refreshToken: encodedRefreshToken, accessToken, expiresIn, tokenType: TOKEN_TYPE, }; } catch (err) { if (err && err.name && JWT_ERROR.has(err.name)) { return createJWTAccessAndRefreshToken( userPayload, secret, encryptSecret, jwtOptions ); } throw err; } }; const createRefreshToken = async ( payload, secret, encryptSecret, jwtOptions = {} ) => { if (!payload) { throw new Error("payload must be provided"); } if (!secret) { throw new Error("JWT secret must be provided"); } if (!encryptSecret) { throw new Error("Encryption secret must be provided"); } const jwtOptionGivenMaybe = isValidObject(jwtOptions) ? jwtOptions : {}; const refreshTokenJwtOptions = Object.assign( { keyid: Date.now().toString() }, JWT_OPTIONS, jwtOptionGivenMaybe, { expiresIn: REFRESH_EXPIRES_IN_TIME } ); const refreshToken = await createJwtToken( payload, secret, refreshTokenJwtOptions ); return encryptString(refreshToken, encryptSecret); }; //=========================== CSRF Token===========================// const createCsrfToken = async ( userId, encryptSecret, expiresIn = CSRF_EXPIRES_IN_TIME ) => { const payload = { id: userId }; const jwtToken = await createJwtToken(payload, encryptSecret, { expiresIn: expiresIn || CSRF_EXPIRES_IN_TIME, }); return encryptString(jwtToken, encryptSecret); }; const isValidCsrfToken = async ( encodeToken, refEncodedToken, encryptSecret ) => { let token = null, refToken = null; try { if (!encodeToken || !refEncodedToken || !encryptSecret) { return { status: false }; } if (encodeToken !== refEncodedToken) { return { status: false }; } token = decryptString(encodeToken, encryptSecret); refToken = decryptString(refEncodedToken, encryptSecret); const [decodedJwtDetails, decodedRefJwtDetails] = await Promise.all([ decodeJwtToken(token, encryptSecret), decodeJwtToken(refToken, encryptSecret), ]); return { status: decodedJwtDetails.id === decodedRefJwtDetails.id }; } catch (e) { if (token && refToken && e && e.name && JWT_ERROR.has(e.name)) { const decodedToken = decodeJwtTokenWithoutValidation(token); const decodedRefToken = decodeJwtTokenWithoutValidation(refToken); if (decodedToken && decodedRefToken) { const isSameId = decodedToken.id === decodedRefToken.id; return isSameId ? { status: isSameId, isExpired: true, } : { status: false }; } } return { status: false }; } }; //========================== Other Helpers=======================// const isValidObject = (obj) => obj !== null && obj !== undefined && typeof obj === "object" && obj.constructor === Object && !isEmpty(obj); const getReqUserSource = (req) => { const ip = req.ip || req.headers["cf-connecting-ip"] || req.headers["x-real-ip"] || req.headers["x-forwarded-for"] || req.socket.remoteAddress || ""; const browser = req.headers["user-agent"]; return { ipAddr: ip, browser }; }; const getCookieName = (cookieId) => `${COOKIE_PREFIX}${cookieId}`; const resetUserCookies = (res, cookieId) => { res.clearCookie(getCookieName(cookieId)); }; const setUserCookies = (cookieDetails, expiresIn, cookieId, res) => { if ( cookieDetails && cookieDetails.refreshToken && cookieDetails.accessToken && res && typeof res.cookie === "function" && cookieId ) { const cookieName = getCookieName(cookieId); const cookieDetailStr = JSON.stringify(cookieDetails); const encryptCookieDetails = encryptString(cookieDetailStr, cookieName); res.cookie(cookieName, encryptCookieDetails, { expire: expiresIn, secure: true, httpOnly: true, sameSite: "lax", }); } }; const getUserCookies = (req, cookieId) => { try { const cookieName = getCookieName(cookieId); const cookieDetails = req.cookies || {}; const systemCookie = cookieDetails[cookieName]; const rawSystemCookie = decryptString(systemCookie, cookieName); return JSON.parse(rawSystemCookie); } catch (err) { return {}; } }; const getUserDetailsFrmMongo = ( mongoUserDetails, includePrivateData = true ) => { const { __v, privateData, ...requiredDetails } = mongoUserDetails; const finalDetails = includePrivateData ? { ...requiredDetails, privateData } : { ...requiredDetails }; return finalDetails; }; const removeUnnecessayUserDetails = ( mongoUserDetails, includeAdminOnlyData = false, includePrivateData = true ) => { const { _id, __v, privateData, csrfToken, refreshToken, password, metadata, ...requiredDetails } = mongoUserDetails; if (includeAdminOnlyData) { return { ...requiredDetails, metadata, privateData }; } const { adminOnly: metaAdmin, ...restMetadata } = metadata; const { adminOnly: privateAdmin, ...restPrivatedata } = privateData; const finalDetails = includePrivateData ? { ...requiredDetails, privateData: restPrivatedata } : { ...requiredDetails }; finalDetails.metadata = restMetadata; finalDetails.mongoId = _id; return finalDetails; }; const getReqUserCsrfToken = (req) => req.headers["X-CSRF-Token"] || req.headers["x-csrf-token"]; //Middlwares //1) Signup - done - testing done //2) Login - done - testing done //3) Reset Password - done - testing done //4) Change Password - done - testing done //5) change Auth Key - done - testing done //6) verify token - done - testing done //7) validate token for Auth verification - done - testing done //8) logout - done - testing done //9) new Ip verify - done - testing done //10) new csrf token - done - testing done //11) generate token for auth verification - done - testing done //12) Reset Password token validator - done - testing done //13) get current user details - done - testing done //14) delete curren user - done - testing done //15) Third party Login - done - testing done class PlugableAuthentication { static #instanceObj = {}; #mongoURI = null; #collectionName = null; #mongoConnection = null; #authKeyName = EMAIL_AUTH_KEY; #secndAuthKeyName = OTP_AUTH_KEY; #newIpAddrTokenName = NEW_IP_ADDR_TOKEN_NAME; #disableEmailValidation = false; #disablePasswordValidation = false; #disableCSRFTokenValidation = false; #disableIpMismatchValidation = false; #model = null; #userSourceModel = null; #validationTokenModel = null; #dataValidationSchema = null; #changeAuthValidationSchema = null; #changePwdValidationSchema = null; #resetPwdValidationSchema = null; #resetPwdVerifyValidationSchema = null; #verifyAuthGenValidationSchema = null; #verifyAuthVerValidationSchema = null; #authKeyValidationPattern = null; #authKeyValidationName = AUTH_KEY_VALIDATION_NAME; #passwordValidationPattern = PASSWORD_VALIDATION_PATTERN; #passwordValidationName = PASSEORD_VALIDATION_NAME; #isModelReady = false; #modelReadyEventEmitter = null; #jwtSecret = null; #jwtOptions = null; #encryptSecret = null; #cookieId = null; #jwtOptnFrIpValidation = null; #csrfTokenExpireTime = null; #tokenSenderFrIpValidationCb = null; #verifyAuthKeyOnCreation = false; #sanitizeObjectBeforeAdd = true; #thirdPartyLoginOption = {}; /** * @param {object} options * @param {string} options.uri * @param {string} options.collection * @param {string} options.jwtSecret * @param {string} options.encryptSecret * @param {string} options.cookieId - 'Id to generate unique cookie name. Must be same for entire app' * @param {string} [options.authKeyName] - default 'email'. It will use as schema name for Db. Package will also search this key name in req.body during login and signup * @param {string} [options.secndAuthKeyName] - default 'otp'. It will use when disablePasswordValidation = true. Package will search this key name in req.body during login and signup * @param {string} [options.newIpAddrTokenName] - default 'token'. It will use get the token value for new IP address. * @param {boolean} [options.disablePasswordValidation]- default 'false' * @param {boolean} [options.disableEmailValidation]- default 'false' * @param {boolean} [options.disableCSRFTokenValidation] - default 'false' * @param {boolean} [options.disableIpMismatchValidation] - default 'false * @param {string} [options.authKeyValidationPattern] -default null * @param {string} [options.authKeyValidationName] -default '"Must be valid characters"' * @param {string} [options.passwordValidationPattern] - default '^[a-zA-Z0-9@$!%*?&^#~_+-]{8,256}$' * @param {string} [options.passwordValidationName] - default '"Must be between 8 and 256 characters"' * @param {object} [options.jwtOptions]- default '{algorithm: 'HS256',noTimestamp: false,expiresIn: '1h',notBefore: '0s'}' * @param {{expiresIn: string}} [options.jwtOptnFrIpValidation] - default null Ex: {expiresIn: "10h"/"7d"} * @param {(shortToken:string,tokenExpiresIn:Date | null,user:{email: string, * id:string, * createdAt:Date, * updatedAt:Date, * metadata?:object, * publicData?:object, * privateData?:object,browser:string,ipAddr:string})=>Promise<void>} [options.sendTokenForIpValidation] - defualt null * @param {string} [options.csrfTokenExpireTime] - default null Ex:"10h"/"7d" * @param {boolean} [options.verifyAuthKeyOnCreation] - default false. * If you want to mark your authentication key as verified on creation. set as true. * @param {boolean} [options.sanitizeObjectBeforeAdd] - default true. * sanitize metadata,privateData and publicData before adding to the database. * @param {{[providerName:string]: * {isPasswordRequired?:boolean, * passwordValidationPattern?:string, * passwordValidationName?:string}}} [options.thirdPartyLoginOption] - default empty object. * `providerName` representing the name of third-party authentication providers (e.g., "google", "facebook",etc.), * `isPasswordRequired` (default false): Indicates whether a password is required for the corresponding authentication provider, * `passwordValidationPattern` (default '^[a-zA-Z0-9@$!%*?&^#~_+-]{8,256}$'): Regex for validating passwords for the corresponding authentication provider,used only when `isPasswordRequired=true`, * `passwordValidationName` (default '"Must be valid characters"'): Message displayed when a password not match `passwordValidationPattern`, used only when `isPasswordRequired=true`. * */ constructor(options) { const { collection } = options; if (!collection || typeof collection !== "string") { throw new Error("Mongo Collection name is required"); } if (PlugableAuthentication.#instanceObj.hasOwnProperty(collection)) { return PlugableAuthentication.#instanceObj[collection]; } PlugableAuthentication.#instanceObj[collection] = this; this.#initalizeInstance(options); return PlugableAuthentication.#instanceObj[collection]; } #initalizeInstance(options) { const { uri, collection, authKeyName, secndAuthKeyName, disableEmailValidation, disablePasswordValidation, authKeyValidationPattern, passwordValidationPattern, disableCSRFTokenValidation, passwordValidationName, authKeyValidationName, jwtSecret, jwtOptions, encryptSecret, cookieId, disableIpMismatchValidation, newIpAddrTokenName, jwtOptnFrIpValidation, sendTokenForIpValidation, csrfTokenExpireTime, verifyAuthKeyOnCreation, sanitizeObjectBeforeAdd, thirdPartyLoginOption, } = options || {}; if (!uri || typeof uri !== "string") { throw new Error("Mongo URI is required"); } if (!collection || typeof collection !== "string") { throw new Error("Mongo Collection name is required"); } if (!jwtSecret || typeof jwtSecret !== "string") { throw new Error("JWT secret is required"); } if (!encryptSecret || typeof encryptSecret !== "string") { throw new Error("Encryption secret is required"); } if (!cookieId || typeof cookieId !== "string") { throw new Error("Cookie ID is required"); } this.#mongoURI = uri; this.#collectionName = collection; this.#jwtSecret = jwtSecret; this.#encryptSecret = encryptSecret; this.#cookieId = cookieId; if (authKeyName && typeof authKeyName === "string") { this.#authKeyName = authKeyName; } if (secndAuthKeyName && typeof secndAuthKeyName === "string") { this.#secndAuthKeyName = secndAuthKeyName; } if (newIpAddrTokenName && typeof newIpAddrTokenName === "string") { this.#newIpAddrTokenName = newIpAddrTokenName; } if (disableEmailValidation && typeof disableEmailValidation === "boolean") { this.#disableEmailValidation = disableEmailValidation; } if ( disablePasswordValidation && typeof disablePasswordValidation === "boolean" ) { this.#disablePasswordValidation = disablePasswordValidation; } if ( !!authKeyValidationPattern && typeof authKeyValidationPattern === "string" ) { this.#authKeyValidationPattern = authKeyValidationPattern; } if ( !!passwordValidationPattern && typeof passwordValidationPattern === "string" ) { this.#passwordValidationPattern = passwordValidationPattern; } if (passwordValidationName && typeof passwordValidationName === "string") { this.#passwordValidationName = passwordValidationName; } if (authKeyValidationName && typeof authKeyValidationName === "string") { this.#authKeyValidationName = authKeyValidationName; } if ( disableCSRFTokenValidation && typeof disableCSRFTokenValidation === "boolean" ) { this.#disableCSRFTokenValidation = disableCSRFTokenValidation; } if (isValidObject(jwtOptions)) { this.#jwtOptions = jwtOptions; } if ( disableIpMismatchValidation && typeof disableIpMismatchValidation === "boolean" ) { this.#disableIpMismatchValidation = disableIpMismatchValidation; } if (isValidObject(jwtOptnFrIpValidation)) { this.#jwtOptnFrIpValidation = { expiresIn: jwtOptnFrIpValidation.expiresIn || JWT_EXPIRES_IN_TIME, }; } if (typeof sendTokenForIpValidation === "function") { this.#tokenSenderFrIpValidationCb = sendTokenForIpValidation; } if (csrfTokenExpireTime && typeof csrfTokenExpireTime === "string") { this.#csrfTokenExpireTime = csrfTokenExpireTime; } if ( verifyAuthKeyOnCreation && typeof verifyAuthKeyOnCreation === "boolean" ) { this.#verifyAuthKeyOnCreation = verifyAuthKeyOnCreation; } if ( typeof sanitizeObjectBeforeAdd === "boolean" && sanitizeObjectBeforeAdd === false ) { this.#sanitizeObjectBeforeAdd = sanitizeObjectBeforeAdd; } this.#processThirdPartyLogin(thirdPartyLoginOption); this.#modelReadyEventEmitter = new EventEmitter(); this.#mongoConnect(uri); } async #mongoConnect(uri) { try { if (!uri) throw new Error("No Mongo URI Found"); const connect = await mongoose.connect(uri); this.#mongoConnection = connect.connection; const { userSourceSchema, authSchema, validationTokenSchema } = createSchemaForCollection( this.#authKeyName, this.#disablePasswordValidation ); const userSourceCollectionName = `${this.#collectionName}_userSource`; const validationCollectionName = `${this.#collectionName}_user_validationToken`; this.#model = mongoose.model(this.#collectionName, authSchema); this.#userSourceModel = mongoose.model( userSourceCollectionName, userSourceSchema ); this.#validationTokenModel = mongoose.model( validationCollectionName, validationTokenSchema ); this.#dataValidationSchema = createSchemaForDataObject( this.#authKeyName, this.#secndAuthKeyName, this.#disableEmailValidation, this.#disablePasswordValidation, this.#authKeyValidationPattern, this.#passwordValidationPattern, this.#authKeyValidationName, this.#passwordValidationName ); this.#changeAuthValidationSchema = createSchemaForChangeAuthDataObject( this.#secndAuthKeyName, this.#disableEmailValidation, this.#disablePasswordValidation, this.#authKeyValidationPattern, this.#passwordValidationPattern, this.#authKeyValidationName, this.#passwordValidationName ); this.#changePwdValidationSchema = createSchemaForChangePwdDataObject( this.#disableEmailValidation, this.#authKeyValidationPattern, this.#passwordValidationPattern, this.#authKeyValidationName, this.#passwordValidationName ); this.#resetPwdValidationSchema = createSchemaForResetPwdDataObject( this.#disableEmailValidation, this.#authKeyValidationPattern, this.#authKeyValidationName ); this.#resetPwdVerifyValidationSchema = createSchemaForResetPwdVerifyDataObject( this.#disableEmailValidation, this.#authKeyValidationPattern, this.#passwordValidationPattern, this.#authKeyValidationName, this.#passwordValidationName ); this.#verifyAuthGenValidationSchema = createSchemaForVerifyAuthGenDataObject( this.#disableEmailValidation, this.#authKeyValidationPattern, this.#authKeyValidationName ); this.#verifyAuthVerValidationSchema = createSchemaForVerifyAuthVerDataObject( this.#disableEmailValidation, this.#authKeyValidationPattern, this.#authKeyValidationName ); this.#isModelReady = true; this.#modelReadyEventEmitter.emit(MODEL_READY_EVENT); console.log(`Connected to Mongo DB URI: ${this.#mongoURI}`); } catch (err) { console.error( `Failed to connect to Mongo DB URI: ${this.#mongoURI}`, err ); throw err; } } #processThirdPartyLogin(thirdPartyLoginOption) { if ( thirdPartyLoginOption !== null && typeof thirdPartyLoginOption === "object" && thirdPartyLoginOption.constructor === Object ) { const entries = Object.entries(thirdPartyLoginOption); for (const entry of entries) { const [key, value] = entry; const { isPasswordRequired, passwordValidationPattern, passwordValidationName, } = value; this.#thirdPartyLoginOption[key] = { schema: isPasswordRequired ? createSchemaForThirdPartyLoginWithPwd( passwordValidationPattern || PASSWORD_VALIDATION_PATTERN, passwordValidationName || PASSEORD_VALIDATION_NAME ) : createSchemaForThirdPartyLoginWithoutPwd(), }; } } } #waitUntilModelReady() { return new Promise((resolve) => { const modelCheckCb = () => { if (this.#isModelReady) { resolve(); } else { this.#modelReadyEventEmitter.on(MODEL_READY_EVENT, resolve); } }; modelCheckCb(); }); } /** * * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] * @param {((requestBody:object, user:object)=>Promise<boolean>)} [params.customValidation] * @param {string} [params.validationLabelName] * */ #loginMiddleware = (params) => { return async (req, res, next) => { const { errorHandler, customValidation, validationLabelName } = params || {}; await this.#waitUntilModelReady(); try { const errorMessage = this.#validateRequestData(req.body); if (errorMessage) { this.#createAndThrowError(errorMessage, 400); } const tokenDetails = await this.#verifyUserLogin( req, customValidation, validationLabelName, this.#jwtOptnFrIpValidation, this.#tokenSenderFrIpValidationCb ); if (!tokenDetails) { const msg = "You're tring to login with different IP address.Please allow this, if you want to countinue."; this.#createAndThrowError(msg, 401); } setUserCookies(tokenDetails, COOKIE_EXPIRES_TIME, this.#cookieId, res); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_LOGIN_FAIL_DEFAULT_MESSAGE ); } }; }; /** * * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] * @param {string} [params.redirectpath] * */ #thirdPartyLoginMiddleware = (params) => { return async (req, res, next) => { const { errorHandler, redirectpath } = params || {}; await this.#waitUntilModelReady(); try { if ( typeof req.user === "object" && req.user !== null && !Array.isArray(req.user) ) { req.body = req.user; } const requestBody = req.body; const { thirdPartyProvider } = requestBody; const isValidThirdPartyProvider = thirdPartyProvider && typeof thirdPartyProvider === "string" && this.#thirdPartyLoginOption[thirdPartyProvider] && this.#thirdPartyLoginOption[thirdPartyProvider].hasOwnProperty( "schema" ); if (!isValidThirdPartyProvider) { const msg = "The third party provider does not exist."; this.#createAndThrowError(msg, 400); } const validationSchema = this.#thirdPartyLoginOption[thirdPartyProvider].schema; const errorMessage = this.#requestDataValidationHelper( validationSchema, requestBody ); if (errorMessage) { this.#createAndThrowError(errorMessage, 400); } const tokenDetails = await this.#verifyThirdPartyUserLogin(req); if (!tokenDetails) { const msg = "You're tring to login with different IP address.Please allow this, if you want to countinue."; this.#createAndThrowError(msg, 401); } setUserCookies(tokenDetails, COOKIE_EXPIRES_TIME, this.#cookieId, res); if (redirectpath && typeof redirectpath === "string") { res.redirect(redirectpath); return; } next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_LOGIN_FAIL_DEFAULT_MESSAGE ); } }; }; /** * @param {object} options * @param {((reqBody:object)=>boolean)|boolean} [options.isCurrentUserVerified] * @param {(err:Error,resp:object)=>void} [options.errorHandler] * */ #signupMiddlware = (options) => { return async (req, res, next) => { await this.#waitUntilModelReady(); const { errorHandler, isCurrentUserVerified } = options || {}; try { const errorMessage = this.#validateRequestData(req.body); if (errorMessage) { this.#createAndThrowError(errorMessage, 400); } await this.#createUser(req, isCurrentUserVerified); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_SIGNUP_FAIL_DEFAULT_MESSAGE ); } }; }; /** * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] */ #newIpAddrCheckMiddleware = (params) => { return async (req, res, next) => { await this.#waitUntilModelReady(); const { errorHandler } = params || {}; try { const requestBody = req.body || {}; const token = requestBody[this.#newIpAddrTokenName]; if (!token || typeof token !== "string") { const msg = '"token" is required.'; this.#createAndThrowError(msg, 400); } const userPayload = await this.#errorRespWrapper( res, this.#verifyValidationToken )(token, NO_NEW_IP_ADDRESS, NO_NEW_IP_ADDRESS); const { userId, ipAddr, browser } = userPayload; await this.#userSourceModel .findOneAndUpdate( { userId, ipAddr, browser }, {}, { lean: true, upsert: true } ) .exec(); await this.#removeValidationToken(token, NO_NEW_IP_ADDRESS); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_IP_ADDR_ADD_FAIL_DEFAULT_MESSAGE ); } }; }; /** * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] */ #logoutMiddleware = (params) => { return async (req, res, next) => { await this.#waitUntilModelReady(); const { errorHandler } = params || {}; try { const cookieDetail = getUserCookies(req, this.#cookieId); const user = await this.#verifyToken(cookieDetail, false); if (!user) { this.#createAndThrowError(NEED_AUTHENTICATION_BEFORE_USE, 401); } await this.#verifyUserIpAddrAndCsrfToken(req, { userId: user.id, csrfToken: user.csrfToken, newIpAddrErrMsg: NEW_IP_ADDR_DURING_LOG_OUT, }); req.adminUser = removeUnnecessayUserDetails(user, true); req.user = removeUnnecessayUserDetails(user); resetUserCookies(res, this.#cookieId); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_LOG_OUT_FAIL_DEFAULT_MESSAGE ); } }; }; /** * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] */ #newCsrfTokenMiddleware = (params) => { return async (req, res, next) => { await this.#waitUntilModelReady(); const { errorHandler } = params || {}; try { const { user } = await this.#validateUserAuthentication(req, { throwErrorOnInvalidCsrfToken: false, throwErrorOnAccessTokenExpire: false, includeUserCsrfToken: false, }); const userId = user.id; const userNewDetails = await this.#createNewCsrfToken(userId); req.adminUser = removeUnnecessayUserDetails(userNewDetails, true); req.user = removeUnnecessayUserDetails(userNewDetails); req.csrfToken = userNewDetails.csrfToken; next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, NEW_CSRF_TOKEN_CREATION_FAIL ); } }; }; /** * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] */ #verifyUserTokenMiddleware = (params) => { return async (req, res, next) => { await this.#waitUntilModelReady(); const { errorHandler } = params || {}; try { const { user, isCsrfTokenExpired } = await this.#validateUserAuthentication(req, { throwErrorOnAccessTokenExpire: false, createNewAccessTokenOnExpires: true, includeUserCsrfToken: true, }); const { tokenDetails, ...restUserDetails } = user; const newUserDetails = await this.#checkAndCreateNewCsrfToken( restUserDetails, isCsrfTokenExpired ); req.adminUser = removeUnnecessayUserDetails(newUserDetails, true); req.user = removeUnnecessayUserDetails(newUserDetails); req.csrfToken = newUserDetails.csrfToken; setUserCookies(tokenDetails, COOKIE_EXPIRES_TIME, this.#cookieId, res); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_TOKEN_VERIFICATION_FAIL_DEFAULT_MESSAGE ); } }; }; /** * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] */ #deleteCurrentUserMiddleware = (params) => { return async (req, res, next) => { await this.#waitUntilModelReady(); const { errorHandler } = params || {}; try { const { user } = await this.#validateUserAuthentication(req, { throwErrorOnAccessTokenExpire: false, createNewAccessTokenOnExpires: false, includeUserCsrfToken: true, }); const userAuth = user[this.#authKeyName]; const userId = user.id; await Promise.all([ this.#model.findOneAndDelete({ id: userId }).exec(), this.#userSourceModel.deleteMany({ userId }).exec(), this.#validationTokenModel .deleteMany({ $or: [{ userId }, { userId: userAuth }], }) .exec(), ]); req.adminUser = removeUnnecessayUserDetails(user, true); req.user = removeUnnecessayUserDetails(user); req.csrfToken = null; resetUserCookies(res, this.#cookieId); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, USER_TOKEN_VERIFICATION_FAIL_DEFAULT_MESSAGE ); } }; }; /** * * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] * @param {((requestBody:object, user:object)=>Promise<boolean>)} [params.customValidation] * @param {string} [params.validationLabelName] * */ #changeAuthenticationValueMiddleware = (params) => { return async (req, res, next) => { const { errorHandler, customValidation, validationLabelName } = params || {}; await this.#waitUntilModelReady(); try { const errorMessage = this.#validateChangeAuthRequestData(req.body); if (errorMessage) { this.#createAndThrowError(errorMessage, 400); } const { user, isCsrfTokenExpired } = await this.#validateUserAuthentication(req, { throwErrorOnAccessTokenExpire: false, createNewAccessTokenOnExpires: true, includeUserCsrfToken: true, }); const { oldAuth, newAuth } = req.body; if (oldAuth === newAuth) { const msg = '"Your old and new authentication information must different."'; this.#createAndThrowError(msg, 400); } const requestBody = { ...(req.body || {}), [this.#authKeyName]: oldAuth, }; await this.#verifyUserFrstAndSecndAuthKey( requestBody, user, customValidation, validationLabelName ); const userForNewAuth = await this.#getUserByQuery( { [this.#authKeyName]: newAuth }, false, "" ); if (userForNewAuth) { const msg = `The given ${this.#authKeyName} has already been taken. Please use different one.`; this.#createAndThrowError(msg, 401); } const newUserDetails = await this.#updateUserByQuery( { id: user.id }, { [this.#authKeyName]: newAuth, isVerified: this.#verifyAuthKeyOnCreation, } ); const { tokenDetails } = user; const restUserDetails = await this.#checkAndCreateNewCsrfToken( newUserDetails, isCsrfTokenExpired ); req.adminUser = removeUnnecessayUserDetails(restUserDetails, true); req.user = removeUnnecessayUserDetails(restUserDetails); req.csrfToken = restUserDetails.csrfToken; setUserCookies(tokenDetails, COOKIE_EXPIRES_TIME, this.#cookieId, res); next(); } catch (e) { return this.#errorHandler( e, res, errorHandler, CHANGE_AUTH_FAIL_MESSAGE ); } }; }; /** * * @param {object} params * @param {(err:Error,resp:object)=>void} [params.errorHandler] * */ #changePasswordMiddleware = (params) => { return async (req, res, next) => { const { errorHandler } = params || {}; await this.#waitUntilModelReady(); try { const errorMessage = this.#validateChangePwdRequestData(req.body); if (errorMessage) { this.#createAndThrowError(errorMessage, 400); } const { user,