/**
## /user/register
Exports the [user] register method for the /api/user/register route.
@requires /view
@requires /user/acl
@requires /user/login
@requires /utils/reqHost
@requires /utils/mailer
@requires /utils/languageTemplates
@requires /utils/bcrypt
@requires crypto
@module /user/register
*/
const bcrypt = require('../utils/bcrypt')
const crypto = require('crypto')
const acl = require('./acl')
const reqHost = require('../utils/reqHost')
const mailer = require('../utils/mailer')
const languageTemplates = require('../utils/languageTemplates')
const view = require('../view')
/**
@function register
@description
Returns the user regestration or password reset form depending on the reset request parameter flag.
Returns the `registerUserBody` method with a request [user] body present.
@param {req} req HTTP request.
@param {res} res HTTP response.
@property {Object} req.params HTTP request parameter.
@property {Object} [req.body]
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.')
}
req.params.host = reqHost(req)
// Register request [post] body.
if (req.body) return registerUserBody(req, res)
// The login view will set the cookie to null.
res.setHeader('Set-Cookie', `${process.env.TITLE}=null;HttpOnly;Max-Age=0;Path=${process.env.DIR || '/'}`)
req.params.template = req.params.reset
? 'password_reset_view'
: 'register_view';
// Get request for registration form view.
view(req, res)
}
const previousAddress = {}
/**
@function registerUserBody
@description
Will attempt to register the user object provided as request body as a new user. The request body JSON object must contain a user email, and password as string entries.
An email with a verification token will be sent to verify a newly registered account.
The `host=string` request parameter is required for the link passed to the user verification mail template.
@param {req} req HTTP request.
@param {res} res HTTP response.
@property {Object} req.params HTTP request parameter.
@property {Object} req.body
Post body object with user data.
*/
async function registerUserBody(req, res) {
debounceRequest(req, res)
if (res.finished) return;
checkUserBody(req, res)
if (res.finished) return;
// The password will be reset for exisiting user accounts.
await passwordReset(req, res)
if (res.finished) return;
// Get the date for logs.
const date = new Date().toISOString().replace(/\..*/, '')
const expiry_date = parseInt((new Date().getTime() + process.env.APPROVAL_EXPIRY * 1000 * 60 * 60 * 24) / 1000)
const USER = {
email: req.body.email,
password: req.body.password,
password_reset: req.body.password,
language: req.body.language,
verificationtoken: req.body.verificationtoken,
access_log: [`${date}@${req.ips && req.ips.pop() || req.ip}`]
}
if (process.env.APPROVAL_EXPIRY) {
USER['expires_on'] = expiry_date
}
// Create new user account
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))
if (rows instanceof Error) {
return res.status(500).send('Failed to access ACL.')
}
await mailer({
template: 'verify_account',
language: req.body.language,
to: req.body.email,
host: req.params.host,
link: `${req.params.host}/api/user/verify/${req.body.verificationtoken}`,
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()
}
/**
@function checkUserBody
@description
The request body JSON object must contain a user email, and password as string entries.
The email address is tested against following regex: `/^[a-zA-Z0-9+_.-]+@[a-zA-Z0-9.-]+$/`.
The ACL can be restricted for email addresses provided as `process.env.USER_DOMAINS`.
A valid password must be provided. Password rules can be defined as `process.env.PASSWORD_REGEXP`. The default rule for password being `'(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])^.{10,}$'`.
The `req.body.password` will be hashed with bcrypt.
A `req.body.verificationtoken` will be created with crypto.
The `req.body.language` will be checked against Intl.Collator.supportedLocalesOf.
@param {req} req HTTP request.
@param {res} res HTTP response.
@property {Object} req.params HTTP request parameter.
@property {Object} req.body
Post body object with user data.
*/
function checkUserBody(req, res) {
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')
}
// Test whether email domain is allowed to register
if (process.env.USER_DOMAINS) {
// Get array of allowed user email domains from split environment variable.
const domains = new Set(process.env.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')
// Create regex to text password complexity from env or set default.
const passwordRgx = new RegExp(process.env.PASSWORD_REGEXP || '(?=.*?[A-Z])(?=.*?[a-z])(?=.*?[0-9])^.{10,}$')
// Test whether the provided password is valid.
if (!passwordRgx.test(req.body.password)) {
res.status(403).send('Invalid password provided')
return;
}
// Hash the password.
req.body.password = bcrypt.hashSync(req.body.password, 8)
// Create random verification token.
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';
}
/**
@function passwordReset
@description
The passwordReset method checks whether a user record exists for the email provided in the request body.
An email with a verification token will be sent to verify the password reset.
@param {req} req HTTP request.
@param {res} res HTTP response.
@property {Object} req.params HTTP request parameter.
@property {Object} req.body
Post body object with user data.
*/
async function passwordReset(req, res) {
// Attempt to retrieve ACL record with matching email field.
let rows = await acl(`
SELECT email, password, password_reset, language, blocked
FROM acl_schema.acl_table
WHERE lower(email) = lower($1);`,
[req.body.email])
if (rows instanceof Error) {
return res.status(500).send('Failed to access ACL.')
}
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')
return;
}
// Blocked user may not reset their password.
if (user.blocked) {
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 expiry_date = parseInt((new Date().getTime() + process.env.APPROVAL_EXPIRY * 1000 * 60 * 60 * 24) / 1000)
const expires_on = process.env.APPROVAL_EXPIRY && user.expires_on
? `expires_on = ${expiry_date},` : ''
const VALUES = [
req.body.email,
req.body.password,
req.body.verificationtoken,
`${date}@${req.params.remote_address}`
]
if (process.env.APPROVAL_EXPIRY && user.expires_on) {
VALUES.push(expiry_date)
}
// Set new password and verification token.
// New passwords will only apply after account verification.
rows = await acl(`
UPDATE acl_schema.acl_table
SET
password_reset = $2,
verificationtoken = $3,
access_log = array_append(access_log, $4)
${process.env.APPROVAL_EXPIRY && user.expires_on ? ',expires_on = $5' : ''}
WHERE lower(email) = lower($1);`,
VALUES)
if (rows instanceof Error) {
return res.status(500).send('Failed to access ACL.')
}
// 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
})
const password_reset_verification = await languageTemplates({
template: 'password_reset_verification',
language: req.body.language
})
res.send(password_reset_verification)
}