UNPKG

vbauth

Version:

This module provides SSO (Single Sign-On) support for vBulletin accounts and your Node.js powered website.

1,042 lines (884 loc) 30.8 kB
/* eslint no-underscore-dangle: ["off"] */ /* eslint class-methods-use-this: "error"*/ const crypto = require('crypto'); const moment = require('moment'); const _debug = require('debug'); const mysql = require('mysql'); const parallel = require('async/parallel'); const series = require('async/series'); const colors = require('colors/safe'); const debug = _debug('vbauth'); debug('Initializing vbauth'); const __DEV__ = process.env.NODE_ENV !== 'production'; // e-mail confirmed / not banned users const _isUser = userinfo => ( userinfo.usergroupid > 1 && userinfo.usergroupid !== 8 && // banned userinfo.usergroupid !== 3 && // awaiting email confirmation userinfo.usergroupid !== 4 // awaiting moderation ); // admins const _isAdmin = userinfo => userinfo.usergroupid === 6; // admins, moderators and super moderators const _isModerator = userinfo => userinfo.usergroupid >= 5 && userinfo.usergroupid <= 7; class VBAuth { constructor(database, options) { if (typeof options.cookieSalt !== 'string' || options.cookieSalt.length === 0) { console.error(colors.red('A cookieSalt must be specified in VBAuth.options! You can find your cookie salt at \'includes/functions.php\' of your vBulletin install folder.\nIf you don\'t specify the correct cookie salt "remember-me cookies" won\'t work')); } if (!database) { throw new Error('You must pass the database information on vbauth initialization'); } else if ({}.hasOwnProperty.call(database, 'connectionLimit') && {}.hasOwnProperty.call(database, 'host') && {}.hasOwnProperty.call(database, 'user') && {}.hasOwnProperty.call(database, 'password') && {}.hasOwnProperty.call(database, 'database')) { this.database = mysql.createPool(database); } else if (!{}.hasOwnProperty.call(database, 'config')) { throw new Error('Invalid MySQL info passed on vbauth initialization'); } else { // an actual mysql pool was passed this.database = database; } this.options = Object.assign({ // Used to salt the remember me password before setting the cookie. // The cookieSalt is located at the file 'includes/functions.php' of your vBulletin install cookieSalt: '', // Cookie prefix used by vBulletin. Defaults to 'bb_'. cookiePrefix: 'bb_', // How long it takes for a session to timeout. cookieTimeout: 900, // Cookie domain. cookieDomain: '', // Default path, for activity refresh. Set a url, or null. null defaults to req.path defaultPath: 'http://my.domain.com', // The strike system will block the user from trying to log in after 5 wrong tries useStrikeSystem: true, // Should it refresh activity or not? If not, it will simply attach the userinfo to the // request object, and will not make any writes or updates in to the Forum database refreshActivity: true, // Use Secure cookies for remember me. Secure cookies only gets stored if transmitted // via TLS/SSL (https sites) secureCookies: false, // Use a redis cache for faster session look-up? // If so, you must pass a redis-client instance to this redisCache: false, // Query user subscriptions? subscriptions: true, // Subscription id to query subscriptionId: 1, // The subnet mask which reflects the level of checking you wish to run // against IP addresses when a session is being fetched. // To check this, go to: // vBulletin Options -> // Server Settings and Optimization Options -> // Session IP Octet Length Check // 255.255.255.255 = 0 // 255.255.255.0 = 1 // 255.255.0.0 = 2 sessionIpOctetLength: 1, // CSRF-Token Key csrfTokenKey: '1234567891123456789112345678911234567891', }, options); this.defaultUserObject = { userid: 0, username: 'unregistered', usergroupid: 1, membergroupids: '', email: '', posts: 0, }; if (this.options.subscriptions) { this.defaultUserObject.subscriptionexpirydate = 0; this.defaultUserObject.subscriptionstatus = 0; } this.isUser = options.isUser || _isUser; this.isModerator = options.isModerator || _isModerator; this.isAdmin = options.isAdmin || _isAdmin; if (typeof this.options.refreshActivity === 'function') { this.refreshActivity = this.options.refreshActivity; } else if (this.options.refreshActivity === true) { this.refreshActivity = () => true; } else { this.refreshActivity = () => false; } if (this.options.csrfTokenKey && this.options.csrfTokenKey.length > 0) { this.hmac = true; } // enable query logging if (debug.enabled) { const originalQuery = this.database.query; this.database.query = (...args) => { if (args.length === 3) { debug(mysql.format(args[0], args[1])); } else if (args.length === 2) { debug(args[0]); } originalQuery.apply(this.database, args); }; } } // Removes ipv6 from ip static _getIpv4(ip) { /* eslint no-param-reassign: "off"*/ const tmpIp = ip || ''; return tmpIp.slice(tmpIp.lastIndexOf(':') + 1); } // Cuts off the the last sessions of an ip address static _getSubstrIp(ip, octectLength, defaultOctectLength) { let length = octectLength; if (octectLength === undefined || octectLength > 3) { length = defaultOctectLength; } const arr = ip.split('.').slice(0, 4 - length); return arr.join('.'); } _getCsrfToken(sessionHash) { return crypto.createHmac('sha256', this.options.csrfTokenKey).update(sessionHash).digest('hex'); } // Unique id based on user ip and user agent _fetchIdHash(req) { const ip = VBAuth._getIpv4(req.ip); const ipSubStr = VBAuth._getSubstrIp(ip, this.options.sessionIpOctetLength); const userAgent = req.header('user-agent'); return crypto.createHash('md5').update(userAgent + ipSubStr).digest('hex'); } _mysqlCreateSession(userid, ip, idHash, sessionHash, url, userAgent, req) { return new Promise((resolve, reject) => { if (!this.refreshActivity(req)) { resolve(sessionHash); return; } const query = mysql.format( 'INSERT INTO session' + ' (userid, sessionhash, host, idhash, lastactivity, location, useragent, loggedin)' + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ userid, sessionHash, ip, idHash, moment().unix(), url, userAgent, (userid > 0), ] ); this.database.query(query, (err) => { if (err) { reject(err); return; } resolve(sessionHash); }); }); } _redisCreateSession(userid, ip, idHash, sessionHash, url, userAgent) { return new Promise((resolve, reject) => { if (!this.options.redisCache) { resolve(); return; } const key = `vbsession:${sessionHash}`; debug('Storing vbsession in redis-cache'); const multi = this.options.redisCache.multi(); multi.hmset(key, { userid, sessionhash: sessionHash, host: ip, idhash: idHash, lastactivity: moment().unix(), location: url, useragent: userAgent, loggedin: (userid > 0), }); multi.expire(key, Math.floor(this.options.cookieTimeout * 0.25)); multi.exec((err) => { if (err) { reject(err); return; } resolve(); }); }); } // Create a new session in the database for the user createSession(req, res, userid /* , loginType*/) { const ip = VBAuth._getIpv4(req.ip); const idHash = this._fetchIdHash(req); const url = (this.options.defaultPath ? this.options.defaultPath : req.path); const userAgent = req.header('user-agent').slice(0, 100); let hash = moment().valueOf().toString() + userid + ip; hash = crypto.createHash('md5') .update(hash) .digest('hex'); // Sets cookie to the response res.cookie(`${this.options.cookiePrefix}sessionhash`, hash, { domain: this.options.cookieDomain, httpOnly: true, }); // Lets also set the csrf token here if (this.hmac) { req.csrfToken = this._getCsrfToken(hash); req.sessionHash = hash; } // Use Redis cache, if available this._redisCreateSession(userid, ip, idHash, hash, url, userAgent); // Returns a promise return this._mysqlCreateSession(userid, ip, idHash, hash, url, userAgent, req); } _mysqlUpdateUserActivity(userid, sessionHash, lastUrl, req) { return new Promise((resolve, reject) => { if (!this.refreshActivity(req)) { resolve(true); return; } const query = mysql.format( `UPDATE session SET lastactivity = ?, location = ?, userid = ?, loggedin = ? WHERE sessionhash = ?`, [ moment().unix(), lastUrl, userid, (userid > 0), sessionHash, ] ); this.database.query(query, (err, result) => { if (err) { reject(err); return; } resolve((result.affectedRows > 0)); }); }); } _redisUpdateUserActivity(userid, sessionHash, lastUrl) { return new Promise((resolve, reject) => { const key = `vbsession:${sessionHash}`; if (!this.options.redisCache) { resolve(false); // redis not available return; } debug('Checking vbsession existance in redis-cache'); this.options.redisCache.exists(key, (err, exists) => { if (err) { reject(err); return; } if (!exists) { resolve(false); // not updated return; } debug('Updating vbsession in redis-cache'); const multi = this.options.redisCache.multi(); multi.hmset(key, { lastactivity: moment().unix(), location: lastUrl, userid, loggedin: (userid > 0), }); multi.expire(key, Math.floor(this.options.cookieTimeout * 0.25)); multi.exec((err2) => { if (err2) { reject(err2); return; } resolve(true); }); }); }); } // Refreshes the user activity in the database, according to its session updateUserActivity(lastUrl, userid, sessionHash, req) { if (!sessionHash || sessionHash.length === 0) { return Promise.resolve(false); } return this._redisUpdateUserActivity(userid, sessionHash, lastUrl) .then((updated) => { if (!updated) { return this._mysqlUpdateUserActivity(userid, sessionHash, lastUrl, req); } return Promise.resolve(updated); }); } // Deletes a session deleteSession(sessionhash, redisOnly, req) { return new Promise((resolve, reject) => { if (typeof sessionhash !== 'string') { resolve(true); return; } parallel([ (cb) => { if (redisOnly || !this.refreshActivity(req)) { return cb(null, true); } const query = mysql.format('DELETE FROM session WHERE sessionhash = ?', [sessionhash]); return this.database.query(query, err => cb(err, true)); }, (cb) => { if (!this.options.redisCache) { return cb(null, true); } debug('Deleting vbsession in redis-cache'); return this.options.redisCache.del(`vbsession:${sessionhash}`, err => cb(err, true)); }, ], (err) => { if (err) { reject(err); return; } resolve(true); }); }); } // Returns the userinfo if the username/password are valid, or null if it's not valid isValidLogin(username, password) { return new Promise((resolve, reject) => { const query = mysql.format('SELECT userid, password FROM user WHERE ' + 'username = ? AND ' + '(password = md5(concat(?, salt)) OR password = md5(concat(md5(?), salt)))', [username, password, password] ); this.database.query(query, (err, rows) => { if (err) { reject(err); return; } resolve(rows[0]); }); }); } // Returns true if the cookie userid/password is valid, false otherwise checkRememberMeCredentials(userid, password) { return new Promise((resolve, reject) => { const query = mysql.format('SELECT userid FROM user' + ' WHERE userid = ? AND md5(concat(password, ?)) = ?', [userid, this.options.cookieSalt, password] ); this.database.query(query, (err, rows) => { if (err) { reject(err); return; } resolve(rows.length > 0); }); }); } _redisStoreUserInfo(userinfo) { return new Promise((resolve, reject) => { if (!this.options.redisCache) { resolve(true); return; } const key = `vbuser:${userinfo.userid}`; debug('Storing vbuser in redis-cache'); const multi = this.options.redisCache.multi(); multi.hmset(key, userinfo); multi.expire(key, Math.floor(this.options.cookieTimeout * 0.25)); multi.exec((err) => { if (err) { reject(err); return; } resolve(true); }); }); } _redisGetUserInfo(userid) { return new Promise((resolve, reject) => { if (!this.options.redisCache) { resolve(null); return; } const key = `vbuser:${userid}`; debug('Fetching vbuser from redis-cache'); this.options.redisCache.hgetall(key, (err, results) => { if (err) { return reject(err); } if (!results) { return resolve(null); } const ret = results; ret.userid = parseInt(results.userid, 10); ret.usergroupid = parseInt(results.usergroupid, 10); ret.posts = parseInt(results.posts, 10); if (this.options.subscriptions) { ret.subscriptionstatus = parseInt(results.subscriptionstatus, 10) || 0; ret.subscriptionexpirydate = parseInt(results.subscriptionexpirydate, 10) || 0; } else { delete ret.subscriptionstatus; delete ret.subscriptionexpirydate; } return resolve(ret); }); }); } _mysqlGetUserInfo(userid) { return new Promise((resolve, reject) => { let subscriptionFields = ''; let subscriptionJoin = ''; if (this.options.subscriptions) { subscriptionFields = ', IFNULL(b.status, 0) AS subscriptionstatus,' + ' IFNULL(b.expirydate, 0) AS subscriptionexpirydate'; subscriptionJoin = 'LEFT JOIN subscriptionlog AS b' + ` ON a.userid = b.userid AND b.subscriptionid = ${mysql.escape(this.options.subscriptionId)}`; } const query = 'SELECT a.userid, a.username, ' + ' a.usergroupid, a.membergroupids,' + ` a.email, a.posts${subscriptionFields}` + ` FROM user AS a ${subscriptionJoin}` + ` WHERE a.userid = ${mysql.escape(userid)}`; this.database.query(query, (err, rows) => { if (err) { reject(err); return; } const userinfo = rows[0]; if (!userinfo) { console.warn('User does not exist:', userid); resolve(null); return; } this._redisStoreUserInfo(userinfo); resolve(userinfo); }); }); } // Get user info getUserInfo(userid) { // Avoid performing this query if the user is not authenticated if (!userid || userid === 0) { return Promise.resolve(Object.assign({}, this.defaultUserObject)); } return this._redisGetUserInfo(userid) .then((userinfo) => { if (!userinfo) { return this._mysqlGetUserInfo(userid); } return Promise.resolve(userinfo); }); } _mysqlGetActiveSession(sessionHash, idHash) { return new Promise((resolve, reject) => { const query = mysql.format('SELECT a.* FROM session AS a' + ' WHERE sessionhash = ? AND idhash = ? AND lastactivity > ?', [sessionHash, idHash, moment().unix() - this.options.cookieTimeout] ); this.database.query(query, (err, rows) => { if (err) { reject(err); return; } const sessionData = rows[0]; if (!sessionData) { console.warn('Session expired:', sessionHash); resolve(null); return; } this._redisCreateSession(sessionData.userid, sessionData.host, idHash, sessionHash, sessionData.location, sessionData.useragent); resolve(sessionData); }); }); } _redisGetActiveSession(sessionHash, idHash) { return new Promise((resolve, reject) => { if (!this.options.redisCache) { resolve(null); return; } debug('Fetching vbsession from redis-cache'); const key = `vbsession:${sessionHash}`; this.options.redisCache.hgetall(key, (err, results) => { if (err) { reject(err); return; } if (!results || results.idhash !== idHash) { debug('vbsession expired from redis-cache!'); resolve(null); } else { resolve(results); } }); }); } // Returns the currently active session, or null if there isn't any getActiveSession(sessionHash, idHash) { return new Promise((resolve, reject) => { if (!sessionHash || sessionHash.length === 0) { resolve(null); return; } this._redisGetActiveSession(sessionHash, idHash) .then((session) => { if (!session) { return this._mysqlGetActiveSession(sessionHash, idHash); } return Promise.resolve(session); }) .then(session => resolve(session)) .catch(err => reject(err)); }); } // Just for code reusing updateOrCreateSession(req, res, sessionHash, userid) { /* eslint no-param-reassign: ["error", { "props": false }] */ return new Promise((resolve, reject) => { // Append user info to request object req.vbuser = { userid }; const url = this.options.defaultPath ? this.options.defaultPath : req.path; // call those in parallel const callArray = [ cb => this.getUserInfo(userid).then(userinfo => cb(null, userinfo)).catch(err => cb(err)), cb => // this will return right away if sessionHash is null, or empty this.updateUserActivity(url, userid, sessionHash, req) .then((updated) => { if (!updated) { // if for some reason his old session wasn't in the db, then just make him a new one // this should never happen, but just in case... return this.createSession(req, res, userid); } return Promise.resolve(sessionHash); }) .then(hash => cb(null, hash)) .catch(err => cb(err)), ]; // After the tasks have been done, we may get back to where we were... parallel(callArray, (err, results) => { if (err) { reject(err); return; } req.vbuser = results[0]; if (this.hmac && !req.csrfToken) { req.csrfToken = this._getCsrfToken(sessionHash); req.sessionHash = sessionHash; } resolve(results[1]); }); }); } // Increases the amount of login tries in the database execStrikeUser(username, userip) { return new Promise((resolve, reject) => { if (!this.options.useStrikeSystem) { resolve(null); return; } const query = mysql.format('INSERT INTO strikes ' + '(striketime, strikeip, username) ' + 'VALUES ' + '(?, ?, ?)', [moment().unix(), userip, username] ); this.database.query(query, (err) => { if (err) { reject(err); return; } resolve(null); }); }); } // Removes his login strikes after a successful login execUnstrikeUser(username, userip) { return new Promise((resolve, reject) => { if (!this.options.useStrikeSystem) { resolve(null); return; } const query = mysql.format( 'DELETE FROM strikes WHERE strikeip = ? AND username = ?', [userip, username] ); this.database.query(query, (err) => { if (err) { reject(err); return; } resolve(null); }); }); } // Checks whether the user can try logging in or not // If he already typed the password wrongly 5 times // in the last 15 minutes he will not be allowed to try again verifyStrikeStatus(username, userip) { return new Promise((resolve, reject) => { if (!this.options.useStrikeSystem) { resolve(0); return; } const deleteQuery = mysql.format('DELETE FROM strikes WHERE striketime < ?', [moment().unix() - 3600] ); const selectQuery = mysql.format('SELECT COUNT(*) AS strikes, MAX(striketime) AS lasttime ' + 'FROM strikes ' + 'WHERE strikeip = ?', [userip] ); series([ cb => this.database.query(deleteQuery, cb), cb => this.database.query(selectQuery, cb), ], (err, results) => { if (err) { reject(err); return; } const strikeResults = results[1][0][0]; if (strikeResults.strikes >= 5 && strikeResults.lasttime > moment().unix() - 900) { // they've got it wrong 5 times or greater for any username at the moment // the user is still not giving up so lets keep increasing this marker this.execStrikeUser(username, userip).catch(err2 => console.warn(err2)); resolve(strikeResults.strikes + 1); return; } resolve(strikeResults.strikes); }); }); } // Authenticates the session, and injects vbuser information // in to the request object (req) authenticateSession(req, res) { debug('Calling authenticateSession()'); // userid and password are set when you login using remember-me let userid = parseInt(req.cookies[`${this.options.cookiePrefix}userid`], 10) || 0; const password = req.cookies[`${this.options.cookiePrefix}password`]; // sessionhash is set everywhere when navigating on vbulletin const sessionHash = req.cookies[`${this.options.cookiePrefix}sessionhash`]; // queried sessionObj let sessionObj = null; return this.getActiveSession(sessionHash, this._fetchIdHash(req)) .then((session) => { // Check if we have remember-me set if (userid && password && (!session || session.userid !== userid)) { return this.checkRememberMeCredentials(userid, password); } // store session we just queried in sessionObj sessionObj = session; // doesn't have a remember me return Promise.resolve(true); }).then((validRememberMe) => { if (!validRememberMe) { // Remember me password was wrong. Lets clear it out! const cookieOption = { domain: this.options.cookieDomain }; res.clearCookie(`${this.options.cookiePrefix}userid`, cookieOption); res.clearCookie(`${this.options.cookiePrefix}password`, cookieOption); userid = 0; } // update session, or create, if user doesn't have any userid = sessionObj ? parseInt(sessionObj.userid, 10) : userid; return this.updateOrCreateSession(req, res, sessionHash, userid); }); } // Logs the user in _login(username, pass, rememberme, loginType, req, res) { const ip = VBAuth._getIpv4(req.ip); const sessionHash = req.cookies[`${this.options.cookiePrefix}sessionhash`]; let userid = 0; return new Promise((resolve, reject) => { if (!username || !pass || username.length === 0 || pass.length === 0) { resolve('failed, login and password are required'); return; } this.verifyStrikeStatus(username, ip).then((strikes) => { if (strikes >= 5) { throw new Error('too many tries'); } return this.isValidLogin(username, pass); }).then((authinfo) => { if (!authinfo) { this.execStrikeUser(username, ip).catch(err => console.warn(err)); throw new Error('wrong login or password'); } userid = authinfo.userid; this.execUnstrikeUser(username, ip); if (rememberme) { // set remember me cookies let hash = authinfo.password + this.options.cookieSalt; hash = crypto.createHash('md5') .update(hash) .digest('hex'); const cookieAge = (365 * 24 * 60 * 60 * 1000); const cookieOptions = { maxAge: cookieAge, domain: this.options.cookieDomain, secure: this.options.secureCookies, httpOnly: true, }; res.cookie(`${this.options.cookiePrefix}userid`, userid, cookieOptions); res.cookie(`${this.options.cookiePrefix}password`, hash, cookieOptions); } return this.createSession(req, res, userid, loginType); }).then(() => { // We just gave him a new session, lets delete his old one, if he had one... if (sessionHash && sessionHash.length > 0) { this.deleteSession(sessionHash, false, req); } return this.getUserInfo(userid); }) .then((userinfo) => { req.vbuser = userinfo; if (this.hmac && !req.csrfToken) { req.csrfToken = this._getCsrfToken(sessionHash); req.sessionHash = sessionHash; } resolve('success'); }) .catch((err) => { if (err.message === 'too many tries' || err.message === 'wrong login or password') { resolve(`failed, ${err.message}`); return; } reject(err); }); }); } // Logs you out logoutSession(req, res) { return new Promise((resolve, reject) => { // Will log you out from the currently set cookie const sessionhash = req.cookies[`${this.options.cookiePrefix}sessionhash`]; // Clear redisCache after vBulletin logout, requires a hook if (req.body && req.body.redisOnly) { this.deleteSession(sessionhash, true, req) .then(() => resolve()) .catch(err => reject(err)); return; } // Logs out from the current cookie session this.deleteSession(sessionhash, false, req).then(() => { req.vbuser = Object.assign({}, this.defaultUserObject); if (req.csrfToken) { delete req.csrfToken; delete req.sessionHash; } const cookieOptions = { domain: this.options.cookieDomain }; res.clearCookie(`${this.options.cookiePrefix}sessionhash`, cookieOptions); res.clearCookie(`${this.options.cookiePrefix}userid`, cookieOptions); res.clearCookie(`${this.options.cookiePrefix}password`, cookieOptions); res.clearCookie(`${this.options.cookiePrefix}imloggedin`, cookieOptions); resolve('success'); }).catch(err => reject(err)); }); } // Middleware wrapper to make mustBe user, moderator, or admin... _mustBeMiddleware(req, res, next, func, errorMsg = 'Invalid request', errorCode = 400) { const tmp = (userinfo) => { if (func(userinfo)) { next(); } else { const err = new Error(errorMsg); err.status = errorCode; next(err); } }; // This will make sure the session won't get re-authenticated // if you had already attached a session() middleware. if (!req.vbuser) { this.authenticateSession(req, res) .then(() => { tmp(req.vbuser); }) .catch((err) => { if (__DEV__) { next(err); return; } console.warn(err); next(new Error('Database error')); }); } else { tmp(req.vbuser); } } /* ******* * * Exports * * ******* */ session() { return (req, res, next) => { if (!req.cookies) { next(new Error('cookie-parser middleware is required for vbauth to work')); return; } this.authenticateSession(req, res) .then(() => next()) .catch((err) => { if (__DEV__) { next(err); return; } console.warn(err); next(new Error('Database error')); }); }; } logout() { return (req, res, next) => { this.logoutSession(req, res) .then((result) => { if (result === 'success') { // logged out, give the user a new empty session // this will prevent session() from trying to query existing session delete req.cookies[`${this.options.cookiePrefix}sessionhash`]; // make new session this.session()(req, res, next); } else { // only deleted from redis-cache, no need to give user a new session next(); } }) .catch((err) => { if (__DEV__) { next(err); return; } console.warn(err); next(new Error('Database error')); }); }; } login(username, pass, rememberme, loginType, req, res) { return new Promise((resolve, reject) => { this._login(username, pass, rememberme, loginType, req, res) .then(result => resolve(result)) .catch((err) => { if (__DEV__) { reject(err); return; } console.warn(err); reject(new Error('Database error')); }); }); } mustBeUser() { return (req, res, next) => { this._mustBeMiddleware(req, res, next, this.isUser); }; } mustBeAdmin() { return (req, res, next) => { this._mustBeMiddleware(req, res, next, this.isAdmin, 'Page not found', 404); }; } mustBeModerator() { return (req, res, next) => { this._mustBeMiddleware(req, res, next, this.isModerator, 'Page not found', 404); }; } mustBe(func) { return (req, res, next) => { this._mustBeMiddleware(req, res, next, func, 'Page not found', 404); }; } } module.exports = VBAuth;