diff --git a/mod/user/_user.js b/mod/user/_user.js index 26be295fb4..90cecd6098 100644 --- a/mod/user/_user.js +++ b/mod/user/_user.js @@ -21,7 +21,7 @@ The _user module exports the user method to route User API requests. @module /user */ -const reqHost = require('../utils/reqHost') +const reqHost = require('../utils/reqHost'); const methods = { add: require('./add'), @@ -36,7 +36,9 @@ const methods = { token: require('./token'), update: require('./update'), verify: require('./verify'), -} +}; + +const previousAddress = {}; /** @function user @@ -49,26 +51,69 @@ The route method assigns the host param from /utils/reqHost before the request a The method request parameter must be an own member of the methods object, eg. `admin`, `register`, `verify`, `add`, `delete`, `update`, `list`, `log`, `key`, `token`, `cookie`, or `login`. +Requests to the user module are debounced by 5 seconds preventing registration, login, etc in quick succession from the same IP address. + @param {Object} req HTTP request. @param {Object} res HTTP response. @param {Object} req.params Request parameter. @param {string} req.params.method Method request parameter. */ - module.exports = async function user(req, res) { - if (!Object.hasOwn(methods, req.params.method)) { + return res.send(`Failed to evaluate 'method' param.`); + } + + if (req.body) { + debounceRequest(req, res); - return res.send(`Failed to evaluate 'method' param.`) + if (res.finished) return; } - req.params.host = reqHost(req) + req.params.host = reqHost(req); - const method = await methods[req.params.method](req, res) + const method = await methods[req.params.method](req, res); if (method instanceof Error) { + req.params.msg = method.message; + methods.login(req, res); + } +}; - req.params.msg = method.message - methods.login(req, res) +/** +@function debounceRequest + +@description +The remote_address determined from the request header is stored in the previousAddress module variable. Requests from the same address within 30 seconds will be bounced. + +@param {req} req HTTP request. +@param {res} res HTTP response. +@property {Object} req.params HTTP request parameter. +@property {Object} req.header HTTP request header. +*/ +function debounceRequest(req, res) { + if (!req.headers['x-forwarded-for']) { + req.params.remote_address = 'unknown'; + } else { + req.params.remote_address = /^[A-Za-z0-9.,_-\s]*$/.test( + req.headers['x-forwarded-for'], + ) + ? req.headers['x-forwarded-for'] + : 'invalid'; } -} \ No newline at end of file + + // The remote_address has been previously used + if ( + Object.hasOwn(previousAddress, req.params.remote_address) && + // within 5 seconds or less. + new Date() - previousAddress[req.params.remote_address] < 5000 + ) { + res + .status(429) + .send(`Address ${req.params.remote_address} temporarily locked.`); + + return; + } + + // Log the remote_address with the current datetime. + previousAddress[req.params.remote_address] = new Date(); +} diff --git a/mod/user/fromACL.js b/mod/user/fromACL.js index 71f85a09a9..6f08a66102 100644 --- a/mod/user/fromACL.js +++ b/mod/user/fromACL.js @@ -14,27 +14,23 @@ This module exports the fromACL method to request and validate a user from the A @module /user/fromACL */ -const bcrypt = require('../utils/bcrypt') +const bcrypt = require('../utils/bcrypt'); -const crypto = require('crypto') +const crypto = require('crypto'); - - -const acl = require('./acl') +const acl = require('./acl'); if (acl === null) { module.exports = null; - } else { - - module.exports = fromACL + module.exports = fromACL; } -const reqHost = require('../utils/reqHost') +const reqHost = require('../utils/reqHost'); -const mailer = require('../utils/mailer') +const mailer = require('../utils/mailer'); -const languageTemplates = require('../utils/languageTemplates') +const languageTemplates = require('../utils/languageTemplates'); /** @function fromACL @@ -55,52 +51,59 @@ The request.email and request.password are taken from the req.body or authorizat Validated user object or an Error if authentication fails. */ async function fromACL(req) { - const request = { email: req.body?.email, password: req.body?.password, language: req.params.language, headers: req.headers, date: new Date(), - host: reqHost(req) - } + host: reqHost(req), + }; if (req.headers.authorization) { + const user_string = Buffer.from( + req.headers.authorization.split(' ')[1], + 'base64', + ).toString(); - const user_string = Buffer.from(req.headers.authorization.split(' ')[1], 'base64').toString() - - const email_password = user_string.split(':') + const email_password = user_string.split(':'); - request.email = email_password[0] + request.email = email_password[0]; - request.password = email_password[1] + request.password = email_password[1]; } // Get remote_address param from request headers. - request.remote_address = /^[A-Za-z0-9.,_-\s]*$/ - .test(req.headers['x-forwarded-for']) + request.remote_address = /^[A-Za-z0-9.,_-\s]*$/.test( + req.headers['x-forwarded-for'], + ) ? req.headers['x-forwarded-for'] : undefined; - if (!request.email) return new Error(await languageTemplates({ - template: 'missing_email', - language: request.language - })) + if (!request.email) + return new Error( + await languageTemplates({ + template: 'missing_email', + language: request.language, + }), + ); - if (!request.password) return new Error(await languageTemplates({ - template: 'missing_password', - language: request.language - })) + if (!request.password) + return new Error( + await languageTemplates({ + template: 'missing_password', + language: request.language, + }), + ); - const user = await getUser(request) + const user = await getUser(request); if (user === undefined) { - // This will happen when a user has a null password. - return new Error('auth_failed') + return new Error('auth_failed'); } - return user + return user; } /** @@ -127,22 +130,26 @@ Returns a validated user object or Error. */ async function getUser(request) { - // Update access_log and return user record matched by email. - let rows = await acl(` + let rows = await acl( + ` UPDATE acl_schema.acl_table SET access_log = array_append(access_log, '${request.date.toISOString().replace(/\..*/, '')}@${request.remote_address}') WHERE lower(email) = lower($1) RETURNING email, roles, language, blocked, approved, approved_by, verified, admin, password ${xyzEnv.APPROVAL_EXPIRY ? ', expires_on;' : ';'}`, - [request.email]) + [request.email], + ); - if (rows instanceof Error) return new Error(await languageTemplates({ - template: 'failed_query', - language: request.language - })) + if (rows instanceof Error) + return new Error( + await languageTemplates({ + template: 'failed_query', + language: request.language, + }), + ); // Get user record from first row. - const user = rows[0] + const user = rows[0]; // If there is no user in the ACL do not throw error. if (!user) return; @@ -152,68 +159,60 @@ async function getUser(request) { // Blocked user cannot login. if (user.blocked) { - return new Error(await languageTemplates({ - template: 'user_blocked', - language: user.language - })) + return new Error( + await languageTemplates({ + template: 'user_blocked', + language: user.language, + }), + ); } // Removes blocked.false flag from user object. - delete user.blocked + delete user.blocked; // Check whether the user approval may have expired. if (await userExpiry(user, request)) { - - return new Error(await languageTemplates({ - template: 'user_expired', - language: user.language - })) + return new Error( + await languageTemplates({ + template: 'user_expired', + language: user.language, + }), + ); } // Accounts must be verified and approved for login if (!user.verified || !user.approved) { - - await mailer({ - template: 'failed_login', - language: user.language, - to: user.email, - host: request.host, - remote_address: request.remote_address - }) - - return new Error('user_not_verified') + return new Error('user_not_verified'); } // Check password from post body against encrypted password from ACL. if (bcrypt.compareSync(request.password, user.password)) { - // password must be removed after check - delete user.password + delete user.password; if (xyzEnv.USER_SESSION) { - // Create a random session token. - user.session = crypto.randomBytes(10).toString('hex') + user.session = crypto.randomBytes(10).toString('hex'); // Store session token in ACL. - rows = await acl(` + rows = await acl( + ` UPDATE acl_schema.acl_table SET session = '${user.session}' WHERE lower(email) = lower($1)`, - [request.email]) + [request.email], + ); // The ACL table may not have a session column. if (rows instanceof Error) { - - return new Error('Unable to store session.') + return new Error('Unable to store session.'); } - } - return user + return user; } - return await failedLogin(request) + return await failedLogin(request); } /** @@ -237,24 +236,23 @@ Admin user accounts do not expire. */ async function userExpiry(user, request) { - // Admin accounts do not not expire. if (user.admin) return false; // APPROVAL_EXPIRY is not configured. if (!xyzEnv.APPROVAL_EXPIRY) return false; - + // Check whether user is expired. if (user.expires_on !== null && user.expires_on < new Date() / 1000) { - if (user.approved) { - // Remove approval of expired user. - await acl(` + await acl( + ` UPDATE acl_schema.acl_table SET approved = false WHERE lower(email) = lower($1);`, - [request.email]) + [request.email], + ); } // User approval has expired. @@ -288,43 +286,51 @@ It is recommended to reset the password for the account if this happens. @returns {Promise} A Promise that resolves with an Error. */ -const maxFailedAttempts = parseInt(xyzEnv.FAILED_ATTEMPTS) +const maxFailedAttempts = parseInt(xyzEnv.FAILED_ATTEMPTS); async function failedLogin(request) { - // Increase failed login attempts counter by 1. - let rows = await acl(` + let rows = await acl( + ` UPDATE acl_schema.acl_table SET failedattempts = failedattempts + 1 WHERE lower(email) = lower($1) - RETURNING failedattempts;`, [request.email]) + RETURNING failedattempts;`, + [request.email], + ); if (rows instanceof Error) { - return new Error(await languageTemplates({ - template: 'failed_query', - language: request.language - })) + return new Error( + await languageTemplates({ + template: 'failed_query', + language: request.language, + }), + ); } // Check whether failed login attempts exceeds limit. - if (rows[0]?.failedattempts >= maxFailedAttempts) { - + if (rows[0]?.failedattempts === maxFailedAttempts) { // Create a verificationtoken. - const verificationtoken = crypto.randomBytes(20).toString('hex') + const verificationtoken = crypto.randomBytes(20).toString('hex'); // Store verificationtoken and remove verification status. - rows = await acl(` + rows = await acl( + ` UPDATE acl_schema.acl_table SET verified = false, verificationtoken = '${verificationtoken}' - WHERE lower(email) = lower($1);`, [request.email]) + WHERE lower(email) = lower($1);`, + [request.email], + ); if (rows instanceof Error) { - return new Error(await languageTemplates({ - template: 'failed_query', - language: request.language - })) + return new Error( + await languageTemplates({ + template: 'failed_query', + language: request.language, + }), + ); } await mailer({ @@ -334,13 +340,20 @@ async function failedLogin(request) { host: request.host, failed_attempts: maxFailedAttempts, remote_address: request.remote_address, - verificationtoken - }) + verificationtoken, + }); + + return new Error( + await languageTemplates({ + template: 'user_locked', + language: request.language, + }), + ); + } - return new Error(await languageTemplates({ - template: 'user_locked', - language: request.language - })) + if (rows[0]?.failedattempts > maxFailedAttempts) { + // Only email once the limit is matched not on every subsequent failed attempt. + return new Error('auth_failed'); } // Login has failed but account is not locked (yet). @@ -349,8 +362,8 @@ async function failedLogin(request) { language: request.language, to: request.email, host: request.host, - remote_address: request.remote_address - }) + remote_address: request.remote_address, + }); - return new Error('auth_failed') -} \ No newline at end of file + return new Error('auth_failed'); +} diff --git a/mod/user/register.js b/mod/user/register.js index c708f29d31..aec6d15c1f 100644 --- a/mod/user/register.js +++ b/mod/user/register.js @@ -16,21 +16,19 @@ Exports the [user] register method for the /api/user/register route. @module /user/register */ -const bcrypt = require('../utils/bcrypt') +const bcrypt = require('../utils/bcrypt'); -const crypto = require('crypto') +const crypto = require('crypto'); - +const acl = require('./acl'); -const acl = require('./acl') +const reqHost = require('../utils/reqHost'); -const reqHost = require('../utils/reqHost') +const mailer = require('../utils/mailer'); -const mailer = require('../utils/mailer') +const languageTemplates = require('../utils/languageTemplates'); -const languageTemplates = require('../utils/languageTemplates') - -const view = require('../view') +const view = require('../view'); /** @function register @@ -48,29 +46,29 @@ Post body object with user data. */ module.exports = async function register(req, res) { - // acl module will export an empty require object without the ACL being configured. if (acl === null) { - return res.status(500).send('ACL unavailable.') + return res.status(500).send('ACL unavailable.'); } - req.params.host = reqHost(req) + req.params.host = reqHost(req); // Register request [post] body. - if (req.body) return registerUserBody(req, res) + if (req.body) return registerUserBody(req, res); // The login view will set the cookie to null. - res.setHeader('Set-Cookie', `${xyzEnv.TITLE}=null;HttpOnly;Max-Age=0;Path=${xyzEnv.DIR || '/'}`) + res.setHeader( + 'Set-Cookie', + `${xyzEnv.TITLE}=null;HttpOnly;Max-Age=0;Path=${xyzEnv.DIR || '/'}`, + ); req.params.template = req.params.reset ? 'password_reset_view' : 'register_view'; // Get request for registration form view. - view(req, res) -} - -const previousAddress = {} + view(req, res); +}; /** @function registerUserBody @@ -90,24 +88,22 @@ Post body object with user data. */ async function registerUserBody(req, res) { - - debounceRequest(req, res) - - if (res.finished) return; - - checkUserBody(req, res) + checkUserBody(req, res); if (res.finished) return; // The password will be reset for exisiting user accounts. - await passwordReset(req, res) + await passwordReset(req, res); if (res.finished) return; // Get the date for logs. - const date = new Date().toISOString().replace(/\..*/, '') + const date = new Date().toISOString().replace(/\..*/, ''); - const expiry_date = parseInt((new Date().getTime() + xyzEnv.APPROVAL_EXPIRY * 1000 * 60 * 60 * 24) / 1000) + const expiry_date = parseInt( + (new Date().getTime() + xyzEnv.APPROVAL_EXPIRY * 1000 * 60 * 60 * 24) / + 1000, + ); const USER = { email: req.body.email, @@ -115,21 +111,23 @@ async function registerUserBody(req, res) { password_reset: req.body.password, language: req.body.language, verificationtoken: req.body.verificationtoken, - access_log: [`${date}@${req.ips && req.ips.pop() || req.ip}`] - } + access_log: [`${date}@${req.ips?.pop() || req.ip}`], + }; if (xyzEnv.APPROVAL_EXPIRY) { - USER['expires_on'] = expiry_date + USER['expires_on'] = expiry_date; } // Create new user account - const rows = await acl(` + const rows = await acl( + ` INSERT INTO acl_schema.acl_table (${Object.keys(USER).join(',')}) VALUES (${Object.keys(USER).map((NULL, i) => `\$${i + 1}`)})`, - Object.values(USER)) + Object.values(USER), + ); if (rows instanceof Error) { - return res.status(500).send('Failed to access ACL.') + return res.status(500).send('Failed to access ACL.'); } await mailer({ @@ -138,47 +136,16 @@ async function registerUserBody(req, res) { to: req.body.email, host: req.params.host, link: `${req.params.host}/api/user/verify/${req.body.verificationtoken}`, - remote_address: req.params.remote_address - }) + remote_address: req.params.remote_address, + }); // Return msg. No redirect for password reset. - res.send(await languageTemplates({ - template: 'new_account_registered', - language: req.body.language - })) -} - -/** -@function debounceRequest - -@description -The remote_address determined from the request header is stored in the previousAddress module variable. Requests from the same address within 30 seconds will be bounced. - -@param {req} req HTTP request. -@param {res} res HTTP response. -@property {Object} req.params HTTP request parameter. -@property {Object} req.header HTTP request header. -*/ - -function debounceRequest(req, res) { - - req.params.remote_address = req.headers['x-forwarded-for'] - && /^[A-Za-z0-9.,_-\s]*$/.test(req.headers['x-forwarded-for']) ? req.headers['x-forwarded-for'] : 'invalid' - || 'unknown'; - - // The remote_address has been previously used - if (Object.hasOwn(previousAddress, req.params.remote_address) - - // within 30 seconds or less. - && new Date() - previousAddress[req.params.remote_address] < 30000) { - - res.status(403).send(`Address ${req.params.remote_address} temporarily locked.`) - - return; - } - - // Log the remote_address with the current datetime. - previousAddress[req.params.remote_address] = new Date() + res.send( + await languageTemplates({ + template: 'new_account_registered', + language: req.body.language, + }), + ); } /** @@ -208,50 +175,50 @@ Post body object with user data. */ function checkUserBody(req, res) { - - if (!req.body.email) return res.status(400).send('No email provided') + if (!req.body.email) return res.status(400).send('No email provided'); // Test email address if (!/^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/.test(req.body.email)) { - - return res.status(400).send('Provided email address is invalid') + return res.status(400).send('Provided email address is invalid'); } // Test whether email domain is allowed to register if (xyzEnv.USER_DOMAINS) { - // Get array of allowed user email domains from split xyzEnvironment variable. - const domains = new Set(xyzEnv.USER_DOMAINS.split(',')) + const domains = new Set(xyzEnv.USER_DOMAINS.split(',')); // Check whether the Set has the domain. if (!domains.has(req.body.email.match(/(?<=@)[^.]+(?=\.)/g)[0])) { - // Return if not... return res.status(400).send('Provided email address is invalid'); } } // Test whether a password has been provided. - if (!req.body.password) return res.status(400).send('No password provided') + if (!req.body.password) return res.status(400).send('No password provided'); // Create regex to text password complexity from xyzEnv or set default. - const passwordRgx = new RegExp(xyzEnv.PASSWORD_REGEXP || '(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])^.{12,}$') + const passwordRgx = new RegExp( + xyzEnv.PASSWORD_REGEXP || '(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])^.{12,}$', + ); // Test whether the provided password is valid. if (!passwordRgx.test(req.body.password)) { - - res.status(403).send('Invalid password provided') + res.status(403).send('Invalid password provided'); return; } // Hash the password. - req.body.password = bcrypt.hashSync(req.body.password, 8) + req.body.password = bcrypt.hashSync(req.body.password, 8); // Create random verification token. - req.body.verificationtoken = crypto.randomBytes(20).toString('hex') + req.body.verificationtoken = crypto.randomBytes(20).toString('hex'); // Lookup the provided language key. - req.body.language = Intl.Collator.supportedLocalesOf([req.body.language], { localeMatcher: 'lookup' })[0] || 'en'; + req.body.language = + Intl.Collator.supportedLocalesOf([req.body.language], { + localeMatcher: 'lookup', + })[0] || 'en'; } /** @@ -270,58 +237,64 @@ Post body object with user data. */ async function passwordReset(req, res) { - // Attempt to retrieve ACL record with matching email field. - let rows = await acl(` + let rows = await acl( + ` SELECT email, password, password_reset, language, blocked FROM acl_schema.acl_table WHERE lower(email) = lower($1);`, - [req.body.email]) + [req.body.email], + ); if (rows instanceof Error) { - return res.status(500).send('Failed to access ACL.') + return res.status(500).send('Failed to access ACL.'); } - const user = rows[0] + const user = rows[0]; // Register new user. if (!user) return; // Setting the password to NULL will disable access to the account and prevent resetting the password. if (user?.password === null) { - res.status(401).send('User account has restricted access') + res.status(401).send('User account has restricted access'); return; } // Blocked user may not reset their password. if (user.blocked) { - res.status(403).send(await languageTemplates({ - template: 'user_blocked', - language: req.body.language - })) + res.status(403).send( + await languageTemplates({ + template: 'user_blocked', + language: req.body.language, + }), + ); return; } // Get the date for logs. - const date = new Date().toISOString().replace(/\..*/, '') + const date = new Date().toISOString().replace(/\..*/, ''); - const expiry_date = parseInt((new Date().getTime() + xyzEnv.APPROVAL_EXPIRY * 1000 * 60 * 60 * 24) / 1000) + const expiry_date = parseInt( + (new Date().getTime() + xyzEnv.APPROVAL_EXPIRY * 1000 * 60 * 60 * 24) / + 1000, + ); const VALUES = [ req.body.email, req.body.password, req.body.verificationtoken, - `${date}@${req.params.remote_address}` - ] + `${date}@${req.params.remote_address}`, + ]; if (xyzEnv.APPROVAL_EXPIRY && user.expires_on) { - - VALUES.push(expiry_date) + VALUES.push(expiry_date); } // Set new password and verification token. // New passwords will only apply after account verification. - rows = await acl(` + rows = await acl( + ` UPDATE acl_schema.acl_table SET password_reset = $2, @@ -329,26 +302,27 @@ async function passwordReset(req, res) { access_log = array_append(access_log, $4) ${xyzEnv.APPROVAL_EXPIRY && user.expires_on ? ',expires_on = $5' : ''} WHERE lower(email) = lower($1);`, - VALUES) + VALUES, + ); if (rows instanceof Error) { - return res.status(500).send('Failed to access ACL.') + return res.status(500).send('Failed to access ACL.'); } - // Sent mail with verification token to the account email address. + // Sent mail with verification token to the account email address. await mailer({ template: 'verify_password_reset', language: req.body.language, to: user.email, host: req.params.host, link: `${req.params.host}/api/user/verify/${req.body.verificationtoken}/?language=${req.body.language}`, - remote_address: req.params.remote_address - }) + remote_address: req.params.remote_address, + }); const password_reset_verification = await languageTemplates({ template: 'password_reset_verification', - language: req.body.language - }) + language: req.body.language, + }); - res.send(password_reset_verification) + res.send(password_reset_verification); }