UNPKG

@horanet/hauth

Version:

Web authentication and authorization module for humans and devices with PG database

413 lines (371 loc) 11.2 kB
const scrypt = require('scrypt-pwd'); const jwt = require('jsonwebtoken'); var defCfg = { // config parameters, populated with default values cookiename: 'hauth', jwt_key: pwgen(), // key used for signing JWT: if not defined, // it is randomly generated at each server startup jwt_alg: 'HS256', // algorithm used for signing JWT jwt_exp: '2h', // expiration time for JWT, default 2 hours }; var extCfg = {}; var db; function checkInit(fun) { return (...args) => { if (!db) throw('Hauth is not inited'); return fun(...args); } } module.exports = { init: init, control: checkInit(control), allowed: checkInit(allowed), getCookie: checkInit(getCookie), delCookie: checkInit(delCookie), getUser: checkInit(getUser), addUser: checkInit(addUser), delUser: checkInit(delUser), modUser: checkInit(modUser), addRoles: checkInit(addRoles), checkUser: checkInit(checkUser), checkPwd: checkInit(checkPwd), updatePwd: checkInit(updatePwd), checkToken: checkInit(checkToken), addCookie: checkInit(addCookie), generateCookie:checkInit(generateCookie) }; async function init(config, dbh) { if (db) { console.warn('Hauth is already inited'); return; } db = dbh; extCfg = config; await checkTableRole(); await addRoles(cfg().roles); await checkTableUser(); } function cfg() { return { ...defCfg, ...extCfg }; } /** * check if a table exists in database */ async function missing(tablename) { const test = await db.query( `SELECT count(*) FROM pg_catalog.pg_tables WHERE tablename=$1`, [tablename] ); return test.rows[0].count === '0'; } /** * check if table 'hauth_role' exists, create if missing */ async function checkTableRole() { if (await missing('hauth_role')) { console.info('Creating table hauth_role') await db.query(` CREATE TABLE hauth_role ( id SERIAL PRIMARY KEY, name VARCHAR(20) NOT NULL, UNIQUE (name) )` ); } } /** * check if table 'hauth_user' exists, create if missing */ async function checkTableUser() { if (await missing('hauth_user')) { console.info('Creating table hauth_user') await db.query(` CREATE TABLE hauth_user ( id SERIAL PRIMARY KEY, login VARCHAR(50) NOT NULL, name VARCHAR(100), role_id INTEGER REFERENCES hauth_role (id) ON DELETE SET NULL ON UPDATE CASCADE, password VARCHAR(100), next_password VARCHAR(100), UNIQUE (login) )` ); for (const user of cfg().defaultUsers) { addUser(user); } } } /** * Populate table hauth_role with roles * @param {*} roles: as defined in the config. E.g. ['admin', 'installer'] */ async function addRoles(roles) { const existingRoles = await db.query(`SELECT name FROM hauth_role`); newRoles = roles.filter( (role) => !existingRoles.rows.map((x) => x.name).includes(role) ); if (newRoles.length) { var i=1; const dollars = newRoles.map((x) => `($${i++})`).join(','); await db.query(`INSERT INTO hauth_role (name) VALUES ${dollars} ON CONFLICT DO NOTHING`, newRoles); } } async function parse(user) { const role = user.role; delete user.role; if (user.password) { user.password = await scrypt.hash(user.password); } var cols = Object.keys(user); var vals = Object.values(user); var i = 0; var dols = vals.map((x) => `$${++i}`); if (role) { cols.push('role_id'); dols.push(`(SELECT id FROM hauth_role WHERE name=$${++i})`); vals.push(role); } const res = [cols.join(','), dols.join(','), vals, i]; return [cols.join(','), dols.join(','), vals, i]; } /** * Add a single user to the hauth_user * @param {*} user JSON object * @returns the new user profile if insert is successful, else null */ async function addUser(user) { var [cols, dols, vals, i] = await parse(user); const result = await db.query(`INSERT INTO hauth_user(${cols}) VALUES (${dols}) ON CONFLICT DO NOTHING RETURNING *`, vals); return result.rowCount > 0 ? result.rows[0] : null; } /** * Modify existing user * @param {*} login : user's or device's login ID * @param {*} user : object containing the fields to be updated * @returns the new user profile if update is successful, else null */ async function modUser(login, user) { var [cols, dols, vals, i] = await parse(user); if (!i) return false; if (i > 1) { [cols, dols] = [`(${cols})`, `(${dols})`]; } const result = await db.query(`UPDATE hauth_user SET ${cols} = ${dols} WHERE login=$${++i}::varchar RETURNING *`, [...vals, login]); return result.rowCount > 0 ? result.rows[0] : null; } /** * Delete existing user * @param {*} user * @returns the removed user profile if delete is successful, else null */ async function delUser(login) { const result = await db.query(`DELETE FROM hauth_user WHERE login=$1 RETURNING *`, [login]); return result.rowCount > 0 ? result.rows[0] : null; } /** * authentication and access control (display page/error page...) * based on access rule of each path user */ async function control(req, res, next) { const rule = getRule(req.path); const hasUser = req.user || checkToken(req) || await checkUser(req, res); let cb; if (rule === 'skip') return next(); if (!hasUser) { res.status(401); cb = cfg().on401; } else if (allowed(req.user.role, req.path, rule)) { return next(); } else { res.status(403); cb = cfg().on403; } if (cb) { cb(req, res); } else { res.send(); } } /** * returns access rule for the path, based on config */ function getRule(url) { for (const [pattern, rule] of Object.entries(cfg().accessRules)) { if (pattern.startsWith('/') ? url.startsWith(pattern) : new RegExp(pattern).test(url)) { return rule; } } return 'allow'; } /** * @param {*} role * @param {*} url * @param {*} rule optional: to avoid rechecking the access rule if it's already checked * @returns true if a user of role <role> is authorized to access <url> */ function allowed(role, url, rule) { rule = rule || getRule(url); if (!rule || rule === 'deny') return false; else if (rule === 'allow' || rule === 'skip') return true; else if (Array.isArray(rule)) return rule.includes(role) ? true : false; else if (new RegExp(rule).test(role)) return true; return false; } /** * Validate the JWT sent in the cookie * @param {*} req HTTP request message * @returns true if the JWT is verified */ function checkToken(req) { const token = req.cookies[cfg().cookiename]; if (token) { try { req.user = jwt.verify(token, cfg().jwt_key, {algorithms: cfg().jwt_alg}); return true; } catch (error) { } } } /** * Verify the credential submitted * @returns true or false */ async function checkUser(req, res) { const [login, pwd] = getUserAndPwdFromReq(req); if (!login || pwd == null) { // pwd may be '' return false; } let [success, user, dbPwd, nextPwd] = await checkPwd(login, pwd); if (success) { if (nextPwd) { res.set('X-Next-Password', nextPwd); } req.user = user; return true; } if (!user) { if (cfg().autocreate) { user = await cfg().autocreate(login, pwd, db, req.headers); if (user) { const next_password = pwgen(); // password generation req.user = user; res.set('X-Next-Password', next_password); await addUser({ ...user, next_password }); console.info(`Created new user ${login}`); return true; } } console.warn(`Attempt to login as ${login}, but this login is unknown`) return false; } if (!dbPwd && nextPwd && cfg().autocreate) { // client did not send next password, and normal password is not defined: // client may not have received next password sent during autocreate process, // so the next password must be re-sent const fakeUser = await cfg().autocreate(login, pwd, db, req.headers); if (fakeUser && fakeUser.login === user.login) { req.user = user; const next_password = pwgen(); // password generation res.set('X-Next-Password', next_password); await modUser(login, { next_password }); console.warn(`Renew and resend ${login}'s next password`) return true; } } console.warn(`Attempt to login as ${login} with bad password`); return false; } async function checkPwd(login, pwd, checkNextPwd = true) { var [user, dbPwd, nextPwd] = await getUser(login); if (!user) { return [false, user, dbPwd, nextPwd]; } if (checkNextPwd && pwd != null && pwd === nextPwd) { // client sent next password updatePwd(login, pwd, true); return [true, user, dbPwd, nextPwd]; } if (dbPwd && await scrypt.verify(pwd, dbPwd)) { // client sent normal password try { if (scrypt.needsRehash(dbPwd)) { updatePwd(login, pwd); } } catch (err) {} return [true, user, dbPwd, nextPwd]; } if (dbPwd && await bcrypt.compare(pwd, dbPwd)) { // client sent normal password, // but it is stored bcrypt-hashed, so it must be re-hashed with scrypt updatePwd(login, pwd); return [true, user, dbPwd, nextPwd]; } return [false, user, dbPwd, nextPwd]; } /** * Extract login and password from request body or from request header 'Authorization: Basic xxx' */ function getUserAndPwdFromReq(req) { const authz = req.headers.authorization; if (authz && authz.startsWith('Basic ')) { const value = Buffer.from(authz.split(' ')[1], 'base64').toString('utf-8'); return value.includes(':') ? value.split(':') : []; } else { return req.body ? [req.body.login, req.body.password] : []; } } /** * Extract user, password and next_password (if applicable) from hauth_user * @param {*} login: user's login ID * @returns */ async function getUser(login) { const query = "SELECT hauth_user.*, hauth_role.name as role FROM hauth_user LEFT JOIN hauth_role ON role_id=hauth_role.id WHERE login=$1"; const users = await db.query(query, [login]); if (users.rowCount) { const user = users.rows[0]; const [pwd, nextPwd] = [user.password, user.next_password]; delete user.password; delete user.next_password; delete user.role_id; return [user, pwd, nextPwd]; } else { return []; } } function updatePwd(login, pwd, resetNextPwd) { scrypt.hash(pwd).then((hash) => { db.query('UPDATE hauth_user SET password=$2' + (resetNextPwd ? ', next_password=NULL' : '') + ' WHERE login=$1', [login, hash]).then(() => { console.log(`${login}'s password hash updated`); }); }); } /** * @returns a pseudo-random password of 10 characters [0-9 a-z] */ function pwgen() { return Math.random().toString(36).substring(2, 12); } function generateCookie(user) { return jwt.sign(user, cfg().jwt_key, {algorithm: cfg().jwt_alg, expiresIn: cfg().jwt_exp}) } function addCookie(req, res) { const token = generateCookie(req.user); res.cookie(cfg().cookiename, token, {httpOnly: true}); res.set('Cache-Control', 'no-cache, private, no-store, must-revalidate'); } async function getCookie(req, res, next) { res.set('Cache-Control', 'no-cache, private, no-store, must-revalidate'); if (await checkUser(req, res)) { addCookie(req, res); res.status(200).send(req.user); } else { res.status(401).send(); } }; function delCookie(req, res, next) { res.clearCookie(cfg().cookiename); if (cfg().onLogout) { cfg().onLogout(req, res); } else { res.send(); } };