UNPKG

zetta-login

Version:

Zetta Toolkit - User Login Framework

745 lines (632 loc) 23.9 kB
// // -- Zetta Toolkit - User Login // // Copyright (c) 2014 ASPECTRON Inc. // All Rights Reserved. // // Permission is hereby granted, free of charge, to any person obtaining a copy // of this software and associated documentation files (the "Software"), to deal // in the Software without restriction, including without limitation the rights // to use, copy, modify, merge, publish, distribute, sublicense, and/or sell // copies of the Software, and to permit persons to whom the Software is // furnished to do so, subject to the following conditions: // // The above copyright notice and this permission notice shall be included in // all copies or substantial portions of the Software. // // THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR // IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, // FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE // AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER // LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, // OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN // THE SOFTWARE. // var fs = require('fs'); var _ = require('underscore'); var events = require('events'); var util = require('util'); var crypto = require('crypto'); var scrypt = require('./scrypt'); var base58 = require('zetta-base58'); var path = require('path'); var ServeStatic = require('serve-static'); var notp = require('notp'); var base32 = require('thirty-two'); // http://stackoverflow.com/questions/14382725/how-to-get-the-correct-ip-address-of-a-client-into-a-node-socket-io-app-hosted-o function getClientIp(req) { var ipAddress; // Amazon EC2 / Heroku workaround to get real client IP var forwardedIpsStr = req.header('x-forwarded-for'); if (forwardedIpsStr) { // 'x-forwarded-for' header may return multiple IP addresses in // the format: "client IP, proxy 1 IP, proxy 2 IP" so take the // the first one var forwardedIps = forwardedIpsStr.split(','); ipAddress = forwardedIps[0]; } if (!ipAddress) { // Ensure getting client IP address still works in // development environment ipAddress = req.connection.remoteAddress; } return ipAddress; } function Login(core, authenticator, options) { var self = this; events.EventEmitter.call(self); self.loginTracking = { } self.throttle = {attempts : 3, min : 3 } if(_.isObject(options.throttle)) _.extend(self.throttle, options.throttle); self.authenticator = authenticator; function getLoginTracking(ip) { var o = self.loginTracking[ip]; if(!o) o = self.loginTracking[ip] = { unblock_ts : 0, attempts : 0, failures : 0 } return o; } self.authenticate = function(args, callback) { if(!self.authenticator) throw new Error("Login constructor requires authenticator argument"); return self.authenticator.authenticate(args, callback); } self._getLogin = function (viewPath, req, res) { res.setHeader('login-required', true); res.render(viewPath, { Client : self.getClientJavaScript() }, function(err, html) { if(err) { console.log(err); return res.end("Server Error"); } res.end(strip(html)); }); }; self.getLogin = function(req, res, next) { self._getLogin(options.view || path.join(__dirname, 'views/login.ejs'), req, res); } self.postChallenge = function(req, res, next) { res.type('application/json'); var ts = Date.now(); var ip = getClientIp(req); var o = getLoginTracking(ip); if(options.throttle && o.unblock_ts > ts) return res.json(401, { error : "Your access to the login system remains blocked for "+getDurationString(o.unblock_ts-ts), throttle : true, ts: parseInt( (o.unblock_ts-ts) / 1000 ) }); o.attempts++; if(options.throttle && o.attempts > self.throttle.attempts) { o.attempts = 0; o.failures++; o.unblock_ts = ts+(self.throttle.min*((o.failures+1)/2)*60*1000); return res.json(401, { error : "Your access to the login system has been blocked for "+getDurationString(o.unblock_ts-ts), throttle : true, ts: parseInt( (o.unblock_ts-ts) / 1000 ) }); } var auth = self.authenticator.getClientAuth(function(err, auth) { req.session.auth = auth; res.json(200, { auth : auth }); }); } self.logout = function(req, res, next) { var user = req.session.user; if(user){ delete req.session.user; self.emit('user-logout', user); } } self.getLogout = function(req, res, next) { self.logout.apply(self, arguments); res.redirect(options.logoutRedirect || '/'); } self.postLogout = function(req, res, next) { self.logout.apply(self, arguments); res.send(200); } self.postLogin = function(req, res, next) { res.type('application/json'); if(!req.session.auth) return res.json(401, { error : "User name and password required" }); if(!req.body.username || !req.body.password || !req.body.sig) return res.json(401, { error : "User name and password required" }); var ts = Date.now(); var ip = getClientIp(req); var o = getLoginTracking(ip); if(options.throttle && o.unblock_ts > ts) return res.json(401, { error : "Your access to the login system remains blocked for another "+getDurationString(o.unblock_ts-ts), throttle : true, ts: parseInt( (o.unblock_ts-ts) / 1000 ) }); self.authenticate({ username : req.body.username, password : req.body.password, auth : req.session.auth, sig : req.body.sig, totpToken : req.body.totpToken }, function(err, user) { delete req.session.auth; if(!user) { if(options.throttle && o.attempts > self.throttle.attempts) { o.attempts = 0; o.failures++; o.unblock_ts = ts+(self.throttle.min*((o.failures+1)/2)*60*1000); res.json(401, { error : "Your access to the login system has been blocked for "+getDurationString(o.unblock_ts-ts), throttle : true, ts: parseInt( (o.unblock_ts-ts) / 1000 ) }); } else { if(err) res.json(401, err); else res.json(401, { error : "Wrong login credentials" }); } } else { if(user.blocked || user.blacklisted) { res.json(401, { error : "User access blocked by administration"}); } else { self.validateUser(user, function(err, userOk) { if(err) res.json(401, err); else { delete self.loginTracking[ip]; req.session.user = user; delete req.session.auth; self.emit('user-login', user); (self.authenticator instanceof events.EventEmitter) && self.authenticator.emit('user-login', user); res.json({ success : true }); } }) } } }) } self.validateUser = function(user, callback) { /* if(!user.confirmed) { return callback({ error : "Waiting for user confirmation"}); } */ callback(null, true); } function strip(str) { return str; //return str.replace(/\s{2,}/g, ' ');//.replace(/ENTER/g,'\n'); //return str.replace(/[\n\r]/g, ' ').replace(/\s{2,}/g, ' ');//.replace(/ENTER/g,'\n'); //return str.replace(/[\n\r]/g, '\t').replace(/ {2,}/g, ' ');//.replace(/\/**\//g,'/*\n*/'); } function getDurationString(d) { var m = Math.floor(d / 1000 / 60); var s = Math.floor(d / 1000 % 60); if(s < 10) s = '0'+s; return m+' min '+s+' sec'; } self.init = function(app) { var _path = options.path || ''; app.get(_path+'/logout', self.getLogout); app.post(_path+'/logout', self.postLogout); app.get(_path+'/login', self.getLogin); app.post(_path+'/challenge', self.postChallenge); app.post(_path+'/login', self.postLogin); app.use('/login/resources', ServeStatic(path.join(__dirname, 'http'))); app.use(function(req, res, next) { if(!req.session.user) return res.redirect('/login'); next(); }) } self.getClientJavaScript = function() { var text = Client.toString(); text = text .replace("CLIENT_PATH", JSON.stringify(options.path || '')) .replace("CLIENT_ARGS", JSON.stringify(self.authenticator.client)); return "("+text+")()"; } } util.inherits(Login, events.EventEmitter); function Authenticator(core, options) { var self = this; events.EventEmitter.call(self); self.client = options.client; self.iterations = options.iterations || 100000; self.keylength = options.keylength || 4096/32; self.saltlength = options.saltlength || 4096/32; function encrypt(text) { if(!text || !options.cipher) return text; var key = _.isString(options.key) ? new Buffer(options.key,'hex') : options.key; var cipher = crypto.createCipher(options.cipher, key); var crypted = cipher.update(text, 'utf8', 'binary'); crypted += cipher.final('binary'); return base58.encode(new Buffer(crypted, 'binary')); } function decrypt(text, callback) { if(!text || !options.cipher) return callback(null, text); var key = _.isString(options.key) ? new Buffer(options.key,'hex') : options.key; base58.decode(text, function(err, data) { if(err) return callback(err); var decipher = crypto.createDecipher(options.cipher, key); var decrypted = decipher.update(data, 'binary', 'utf8'); decrypted += decipher.final('utf8'); callback(null, decrypted); }); } function hex2uint8array(hex) { var bytes = new Uint8Array(hex.length/2); for(var i=0; i< hex.length-1; i+=2){ bytes[i] = (parseInt(hex.substr(i, 2), 16)); } return bytes; } self.getClientAuth = function(callback) { crypto.randomBytes(256, function(err, bytes) { if(err) return callback(err); callback(null, bytes.toString('hex')); }) } self.generatePBKDF2 = function(password, salt, iterations, keylength, callback) { crypto.pbkdf2(password, salt, iterations, keylength, function(err, key) { if(err) return callback(err); var res = ['pbkdf2', iterations, keylength, base58.encode(key), base58.encode(salt)].join(':'); callback(null, res); }) } self.generateStorageHash = function(password, salt, callback) { if(!password) return callback('No password provided') if(_.isFunction(salt)) { callback = salt; salt = undefined; } if(_.isString(salt)) { salt = new Buffer(salt, 'hex'); } if(!salt) { crypto.randomBytes(self.saltlength, function(err, _salt) { if(err) return callback(err); self.generatePBKDF2(password, _salt, self.iterations, self.keylength, function(err, key) { callback(err, encrypt(key)); }) }); } else { self.generatePBKDF2(password, salt, self.iterations, self.keylength, function(err, key) { callback(err, encrypt(key)); }) } } self.generateExchangeHash = function(password, callback) { if(options.client.sha256) { var hash = crypto.createHash('sha256').update(password).digest('hex'); callback(null, hash); } else if(options.client.scrypt) { var sc = options.client.scrypt; var hash = scrypt.crypto_scrypt(scrypt.encode_utf8(password), hex2uint8array(sc.salt), sc.n, sc.r, sc.p, sc.keyLength); callback(null, scrypt.to_hex(hash)); } } self.compareStorageHash = function(args, _hash, callback) { var hash = decrypt(_hash, function(err, hash) { if(err) return callback(err); var parts = hash.split(':'); if(parts.length != 5 || parts[0] != 'pbkdf2' || parseInt(parts[1]) != self.iterations) return callback({ error : "Wrong encoded hash parameters"}) var iterations = parseInt(parts[1]); var keylength = parseInt(parts[2]); base58.decode(parts[4], function(err, salt) { if(err) return callback(err); self.generatePBKDF2(args.password, salt, iterations, keylength, function(err, key) { if(err) return callback(err); callback(null, hash === key); }) }); }); } self.validateSignature = function(args, callback) { var sig = crypto.createHmac('sha256', new Buffer(args.auth, 'hex')).update(new Buffer(args.password, 'hex')).digest('hex'); callback(null, args.sig == sig); } self.compare = function(args, storedHash, callback) { self.validateSignature(args, function(err, match) { if(!match) return callback({ error : "Wrong authentication signature"}); self.compareStorageHash(args, storedHash, function(err, match) { if(err) return callback(err); if(!match) return callback({ error : "Unknown user name or password"}) callback(null, true); }) }) }; self.generateTotpSecretKey = function(length) { length = length || 10; return crypto.randomBytes(length).toString('hex'); }; self.getTotpKeyForGoogleAuthenticator = function (key) { return base32.encode(key); }; /*self.getBarcodeUrlPart = function (email, key) { return encodeURIComponent('otpauth://totp/' + email + '?secret=' + self.getTotpKeyForGoogleAuthenticator(key)); }; */ self.getBarcodeUrlPart = function (email, key) { return 'otpauth://totp/' + email + '?secret=' + self.getTotpKeyForGoogleAuthenticator(key); }; self.getBarcodeUrlForGoogleAuthenticator = function (email, key) { return 'https://www.google.com/chart?chs=200x200&chld=M|0&cht=qr&chl=' + self.getBarcodeUrlPart(email, key) }; self.getDataForGoogleAuthenticator = function (email, key) { if (!key) return {}; return { totpKey: self.getTotpKeyForGoogleAuthenticator(key), barcodeUrl: self.getBarcodeUrlForGoogleAuthenticator(email, key), barcodeUrlPart: self.getBarcodeUrlPart(email, key) }; } self.verifyTotpToken = function (token, key) { return notp.totp.verify(token, key, {}); }; } util.inherits(Authenticator, events.EventEmitter); function BasicAuthenticator(core, options) { var self = this; Authenticator.apply(self, arguments); if(!options.users) throw new Error("BasicAuthenticator requires 'users' in options"); self.authenticate = function(args, callback) { var username = args.username.toLowerCase(); var password = options.users[username] ? options.users[username].password : null; if(!password) return callback(null, false); self.compare(args, password, function(err, match) { if(err || !match) return callback(err, match); if (options.users[username] && options.users[username].totp && options.users[username].totp.enabled) { if (!args.totpToken) { return callback({request: 'TOTP'}); } if (!self.verifyTotpToken(args.totpToken, options.users[username].totp.key)) { return callback({error : "Wrong one time password"}); } } callback(err, { username : username, success : true }) }) } } util.inherits(BasicAuthenticator, Authenticator); function MongoDbAuthenticator(core, options) { var self = this; Authenticator.apply(self, arguments); if(!options.collection) throw new Error("MongoDbAuthenticator requires 'collection' arguments."); self.users = {}; self.cacheTime = 5; // 5 minutes var _username = options.username || 'email'; var _password = options.password || 'password'; self.authenticate = function(args, callback) { if (self.users[args.username]) { var user = self.users[args.username]; self.compare(args, user[_password], function(err, match) { if(err || !match) return callback(err, match); if (user.totp && user.totp.enabled) { if (!args.totpToken) { return callback({request: 'TOTP'}); } if (!self.verifyTotpToken(args.totpToken, user.totp.key)) { return callback({error : "Wrong one time password"}); } else { delete self.users[args.username]; } } callback(err, user) }); } else { var q = { } q[_username] = args.username; options.collection.findOne(q, function (err, user) { if (err || !user) return callback({ error : 'Wrong user name or password' }); self.compare(args, user[_password], function(err, match) { if(err || !match) return callback(err, match); if (user.totp && user.totp.enabled) { self.users[args.username] = user; self.users[args.username].created = Date.now(); if (!args.totpToken) { return callback({request: 'TOTP'}); } if (!self.verifyTotpToken(args.totpToken, user.totp.key)) { return callback({error : "Wrong one time password"}); } else { delete self.users[args.username]; } } callback(err, user) }) }); } } // clean old user from memory setInterval(function() { _.each(self.users, function(user, i) { if (Date.now() - user.created > self.cacheTime * 1000 * 60) { delete self.users[i]; } }); }, 1000 * 60 * 5); self.on('user-login', function(user) { var conf = options.updateCollection; if (conf == false) return; var collection = options.collection, fieldName = 'last_login'; if (_.isObject(conf)) { collection = conf.collection || collection; fieldName = conf.fieldName || fieldName; }else if(_.isString(conf) ){ fieldName = conf; } var q = { }, data = {}; if (user[_username]) q[_username] = user[_username]; else if (user._id || user.id) q._id = user._id || user.id; else return; data[fieldName] = Date.now(); collection.update(q, { $set : data}, {safe:true}, function(err) { //console.log('collection.update'.red, err) }) }); } util.inherits(MongoDbAuthenticator, Authenticator); function ZettaRpcAuthenticator(core, options) { var self = this; Authenticator.apply(self, arguments); if(!options.rpc) throw new Error("ZettaRpcAuthenticator requires 'rpc' arguments."); var rpc = options.rpc; self.authenticate = function(args, callback) { rpc.dispatch({ op : 'user-auth'}, function(err, user) { if (err || !user) return callback({ error : 'Wrong user name or password' }); self.compare(args, user.password, function(err, match) { if(err || !match) return callback(err, match); callback(err, user); }) }) } } util.inherits(ZettaRpcAuthenticator, Authenticator); var Client = function() { var self = this; self.args = CLIENT_ARGS; self.path = CLIENT_PATH; function require(filename) { var script = document.createElement('script'); script.setAttribute("type","text/javascript"); script.setAttribute('src',filename); document.head.appendChild(script); } var files = [ "/login/resources/hmac-sha256.js", "/login/resources/scrypt.js", "/login/resources/jquery.min.js" ]; function digest() { var file = files.shift(); if(!file) return finish(); var script = document.createElement('script'); script.setAttribute("type","text/javascript"); script.setAttribute('src',file); script.onload = function(){ setTimeout(digest); }; /* for IE Browsers */ ieLoadBugFix(script, function(){ setTimeout(digest); }); function ieLoadBugFix(scriptElement, callback) { if (scriptElement.readyState=='loaded' || scriptElement.readyState=='completed') callback(); else setTimeout(function() { ieLoadBugFix(scriptElement, callback); }, 100); } document.head.appendChild(script); } function finish() { self.scrypt = scrypt_module_factory(); self.onReady_ && self.onReady_.call(self, self); } self.ready = function(callback) { self.onReady_ = callback; }; function hex2uint8array(hex) { var bytes = new Uint8Array(hex.length/2); for(var i=0; i< hex.length-1; i+=2){ bytes[i] = parseInt(hex.substr(i, 2), 16); } return bytes; } self.encrypt = function(username, password, salt, callback) { if(!username) return callback({ error : "Please supply username."}); if(!password) return callback({ error : "Please supply password."}); var hash = null; if(self.args.scrypt) { var ts = Date.now(); var sc = self.args.scrypt; hash = self.scrypt.crypto_scrypt(self.scrypt.encode_utf8(password), hex2uint8array(sc.salt), sc.n, sc.r, sc.p, sc.keyLength); hash = self.scrypt.to_hex(hash); } else { hash = CryptoJS.SHA256(CryptoJS.enc.Utf8.parse(password)).toString(); } var sig = CryptoJS.HmacSHA256(CryptoJS.enc.Hex.parse(hash), CryptoJS.enc.Hex.parse(salt)).toString(); callback(null, { username : username, password : hash, sig : sig }); } function post(path, data, callback) { $.ajax({ dataType: "json", method : 'POST', url: path, data: data, error : function(err) { if(err.responseJSON) { if (err.responseJSON.error) callback(err.responseJSON); else if (err.responseJSON.request) { callback({ request : err.responseJSON.request }); } } else callback({ error : err.statusText }); }, success: function(o) { callback(null, o); } }) } self.post = function(data, callback) { if(!data || !data.username || !data.password) return callback({ error : "Please enter user name and password"}); var totpToken = data.totpToken; post(self.path+'/challenge', {}, function(err, challenge) { if(err) return callback(err); self.encrypt(data.username, data.password, challenge.auth, function(err, data) { if(err) return callback(err); data.totpToken = totpToken; post(self.path+'/login',data, function(err, resp) { callback(err, resp); }) }) }) } digest(); } module.exports = { Login : Login, Authenticator : Authenticator, BasicAuthenticator : BasicAuthenticator, MongoDbAuthenticator : MongoDbAuthenticator }