├── .gitignore ├── .npmignore ├── CHANGELOG.md ├── Gruntfile.js ├── LICENSE ├── README.md ├── index.js ├── lib └── redisstore.js ├── package.json └── test └── redisstore.js /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Deployed apps should consider commenting this line out: 24 | # see https://npmjs.org/doc/faq.html#Should-I-check-my-node_modules-folder-into-git 25 | node_modules 26 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | .git* 2 | examples/ 3 | test/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 1.0.2 (2016-03-25) 2 | Bugfixes: 3 | - FIX storeOrUpdate to return an empty string as referrer if it was passed as null (in line with spec) 4 | - UPDATE dependencies 5 | 6 | # 1.0.1 (2015-08-22) 7 | Bugfixes: 8 | - FIX API documentation which incorrectly referred to MongoDB 9 | 10 | # 1.0.0 (2015-07-13) 11 | Features: 12 | - UPDATE of all requires incl. Redis 13 | 14 | Bugfixes: 15 | - FIX Throws were not detected properly 16 | - FIX Empty initialization of RedisStore() caused issues 17 | 18 | # 0.0.1 (2014-06-28) 19 | - Initial release -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | module.exports = function(grunt) { 2 | 3 | grunt.loadNpmTasks('grunt-mocha-test'); 4 | 5 | grunt.initConfig({ 6 | mochaTest: { 7 | test: { 8 | options: { 9 | reporter: 'spec' 10 | }, 11 | src: ['test/**/*.js'] 12 | } 13 | } 14 | }); 15 | 16 | grunt.registerTask('test', [ 'mochaTest']); 17 | }; -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2014 Florian Heinemann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Passwordless-RedisStore 2 | 3 | This module provides token storage for [Passwordless](https://github.com/florianheinemann/passwordless), a node.js module for express that allows website authentication without password using verification through email or other means. Visit the project's [website](https://passwordless.net) for more details. 4 | 5 | Tokens are stored in a Redis database and are hashed and salted using [bcrypt](https://github.com/ncb000gt/node.bcrypt.js/). 6 | 7 | ## Usage 8 | 9 | First, install the module: 10 | 11 | `$ npm install passwordless-redisstore --save` 12 | 13 | Afterwards, follow the guide for [Passwordless](https://github.com/florianheinemann/passwordless). A typical implementation may look like this: 14 | 15 | ```javascript 16 | var passwordless = require('passwordless'); 17 | var RedisStore = require('passwordless-redisstore'); 18 | 19 | passwordless.init(new RedisStore(6379, '127.0.0.1')); 20 | 21 | passwordless.addDelivery( 22 | function(tokenToSend, uidToSend, recipient, callback) { 23 | // Send out a token 24 | }); 25 | 26 | app.use(passwordless.sessionSupport()); 27 | app.use(passwordless.acceptToken()); 28 | ``` 29 | 30 | ## Initialization 31 | 32 | ```javascript 33 | new RedisStore([port], [host], [options]); 34 | ``` 35 | * **[port]:** *(Number)* Optional. Port of your Redis server. Defaults to: 6379 36 | * **[host]:** *(String)* Optional. Your Redis server. Defaults to: '127.0.0.1' 37 | * **[options]:** *(Object)* Optional. This can include options of the node.js Redis client as described in the [docs](https://github.com/mranney/node_redis) and the ones described below combined in one object as shown in the example 38 | 39 | Example: 40 | ```javascript 41 | passwordless.init(new RedisStore(6379, '127.0.0.1', { 42 | // option of the node.js redis client 43 | auth_pass: 'password', 44 | // options of RedisStore 45 | redisstore: { 46 | database: 15, 47 | tokenkey: 'token:' 48 | } 49 | })); 50 | ``` 51 | 52 | ### Options 53 | * **[redisstore.database]:** *(Number)* Optional. Database to be used. Defaults to: 0 54 | * **[redisstore.tokenkey]:** *(String)* Optional. Keys to be used. UIDs will be appended. Defaults to: 'pwdless:UID' 55 | 56 | ## Hash and salt 57 | As the tokens are equivalent to passwords (even though only for a limited time) they have to be protected in the same way. passwordless-redisstore uses [bcrypt](https://github.com/ncb000gt/node.bcrypt.js/) with automatically created random salts. To generate the salt 10 rounds are used. 58 | 59 | ## Tests 60 | 61 | `$ npm test` 62 | 63 | ## License 64 | 65 | [MIT License](http://opensource.org/licenses/MIT) 66 | 67 | ## Author 68 | Florian Heinemann [@thesumofall](http://twitter.com/thesumofall/) 69 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = require('./lib/redisstore.js'); -------------------------------------------------------------------------------- /lib/redisstore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var util = require('util'); 4 | var bcrypt = require('bcrypt'); 5 | var TokenStore = require('passwordless-tokenstore'); 6 | var redis = require('redis'); 7 | var async = require('async'); 8 | 9 | /** 10 | * Constructor of RedisStore 11 | * @param {Number} [port] Port of the Redis server (default: 6379) 12 | * @param {String} [host] Host, e.g. 127.0.0.1 (default: 127.0.0.1) 13 | * @param {Object} [options] Combines both the options for the Redis client as well 14 | * as the options for RedisStore. For the Redis options please refer back to 15 | * the documentation: https://github.com/NodeRedis/node_redis. RedisStore accepts 16 | * the following options: (1) { redisstore: { database: Number }} number of the 17 | * Redis database to use (default: 0), (2) { redisstore: { tokenkey: String }} the 18 | * prefix used for the RedisStore entries in the database (default: 'pwdless:') 19 | * @constructor 20 | */ 21 | function RedisStore(port, host, options) { 22 | port = port || 6379; 23 | host = host || '127.0.0.1'; 24 | this._options = options || {}; 25 | this._options.redisstore = this._options.redisstore || {}; 26 | if(this._options.redisstore.database && !isNumber(this._options.redisstore.database)) { 27 | throw new Error('database has to be a number (if provided at all)'); 28 | } else if(this._options.redisstore.database) { 29 | this._database = this._options.redisstore.database; 30 | } else { 31 | this._database = 0; 32 | } 33 | 34 | this._tokenKey = this._options.redisstore.tokenkey || 'pwdless:'; 35 | delete this._options.redisstore; 36 | 37 | this._client = redis.createClient(port, host, this._options); 38 | } 39 | 40 | util.inherits(RedisStore, TokenStore); 41 | 42 | /** 43 | * Checks if the provided token / user id combination exists and is 44 | * valid in terms of time-to-live. If yes, the method provides the 45 | * the stored referrer URL if any. 46 | * @param {String} token to be authenticated 47 | * @param {String} uid Unique identifier of an user 48 | * @param {Function} callback in the format (error, valid, referrer). 49 | * In case of error, error will provide details, valid will be false and 50 | * referrer will be null. If the token / uid combination was not found 51 | * found, valid will be false and all else null. Otherwise, valid will 52 | * be true, referrer will (if provided when the token was stored) the 53 | * original URL requested and error will be null. 54 | */ 55 | RedisStore.prototype.authenticate = function(token, uid, callback) { 56 | if(!token || !uid || !callback) { 57 | throw new Error('TokenStore:authenticate called with invalid parameters'); 58 | } 59 | 60 | var self = this; 61 | self._select(function(err) { 62 | if(err) { 63 | return callback(err, false, null); 64 | } 65 | var key = self._tokenKey + uid; 66 | self._client.hgetall(key, function(err, obj) { 67 | if(err) { 68 | return callback(err, false, null); 69 | } 70 | else if(!obj) { 71 | return callback(null, false, null); 72 | } else if(Date.now() > obj.ttl) { 73 | callback(null, false, null); 74 | } else { 75 | bcrypt.compare(token, obj.token, function(err, res) { 76 | if(err) { 77 | callback(err, false, null); 78 | } else if(res) { 79 | callback(null, true, obj.origin); 80 | } else { 81 | callback(null, false, null); 82 | } 83 | }); 84 | } 85 | }); 86 | }) 87 | }; 88 | 89 | /** 90 | * Stores a new token / user ID combination or updates the token of an 91 | * existing user ID if that ID already exists. Hence, a user can only 92 | * have one valid token at a time 93 | * @param {String} token Token that allows authentication of _uid_ 94 | * @param {String} uid Unique identifier of an user 95 | * @param {Number} msToLive Validity of the token in ms 96 | * @param {String} originUrl Originally requested URL or null 97 | * @param {Function} callback Called with callback(error) in case of an 98 | * error or as callback() if the token was successully stored / updated 99 | */ 100 | RedisStore.prototype.storeOrUpdate = function(token, uid, msToLive, originUrl, callback) { 101 | if(!token || !uid || !msToLive || !callback || !isNumber(msToLive)) { 102 | throw new Error('TokenStore:storeOrUpdate called with invalid parameters'); 103 | } 104 | 105 | var self = this; 106 | self._select(function(err) { 107 | if(err) { 108 | return callback(err, false, null); 109 | } 110 | bcrypt.hash(token, 10, function(err, hashedToken) { 111 | if(err) { 112 | return callback(err); 113 | } 114 | 115 | originUrl = originUrl || ""; 116 | var key = self._tokenKey + uid; 117 | self._client.hmset(key, { 118 | token: hashedToken, 119 | origin: originUrl, 120 | ttl: (Date.now() + msToLive) 121 | }, function(err, res) { 122 | if(!err) { 123 | msToLive = Math.ceil(msToLive / 1000); 124 | self._client.expire(key, msToLive, function(err, res) { 125 | if(err) 126 | callback(err); 127 | else 128 | callback(); 129 | }) 130 | } else { 131 | callback(err); 132 | } 133 | }); 134 | }); 135 | }) 136 | } 137 | 138 | /** 139 | * Invalidates and removes a user and the linked token 140 | * @param {String} user ID 141 | * @param {Function} callback called with callback(error) in case of an 142 | * error or as callback() if the uid was successully invalidated 143 | */ 144 | RedisStore.prototype.invalidateUser = function(uid, callback) { 145 | if(!uid || !callback) { 146 | throw new Error('TokenStore:invalidateUser called with invalid parameters'); 147 | } 148 | 149 | var self = this; 150 | self._select(function(err) { 151 | if(err) { 152 | return callback(err, false, null); 153 | } 154 | var key = self._tokenKey + uid; 155 | self._client.del(key, function(err) { 156 | if(err) 157 | callback(err); 158 | else 159 | callback(); 160 | }); 161 | }) 162 | } 163 | 164 | /** 165 | * Removes and invalidates all token 166 | * @param {Function} callback Called with callback(error) in case of an 167 | * error or as callback() otherwise 168 | */ 169 | RedisStore.prototype.clear = function(callback) { 170 | if(!callback) { 171 | throw new Error('TokenStore:clear called with invalid parameters'); 172 | } 173 | 174 | var self = this; 175 | self._select(function(err) { 176 | if(err) { 177 | return callback(err, false, null); 178 | } 179 | var pattern = self._tokenKey + '*'; 180 | self._client.keys(pattern, function(err, matches) { 181 | if(err) { 182 | callback(err); 183 | } else if(matches.length > 0) { 184 | async.each(matches, function(match, matchCallback) { 185 | self._client.del(match, matchCallback); 186 | }, callback); 187 | } else { 188 | callback(); 189 | } 190 | }); 191 | }) 192 | } 193 | 194 | /** 195 | * Number of tokens stored (no matter the validity) 196 | * @param {Function} callback Called with callback(null, count) in case 197 | * of success or with callback(error) in case of an error 198 | */ 199 | RedisStore.prototype.length = function(callback) { 200 | if(!callback) { 201 | throw new Error('TokenStore:length called with invalid parameters'); 202 | } 203 | 204 | var self = this; 205 | self._select(function(err) { 206 | if(err) { 207 | return callback(err, false, null); 208 | } 209 | var pattern = self._tokenKey + '*'; 210 | self._client.keys(pattern, function(err, matches) { 211 | if(err) { 212 | callback(err); 213 | } else { 214 | callback(null, matches.length); 215 | } 216 | }); 217 | }) 218 | } 219 | 220 | RedisStore.prototype._select = function(callback) { 221 | var self = this; 222 | if(self._selected) { 223 | callback(); 224 | } else { 225 | self._client.select(self._database, function(err, res) { 226 | if(err) { 227 | callback(err); 228 | } else { 229 | self._selected = true; 230 | callback(); 231 | } 232 | }); 233 | } 234 | } 235 | 236 | function isNumber(n) { 237 | return !isNaN(parseFloat(n)) && isFinite(n); 238 | } 239 | 240 | module.exports = RedisStore; 241 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "passwordless-redisstore", 3 | "version": "1.0.2", 4 | "description": "Redis TokenStore for Passwordless", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "grunt test" 8 | }, 9 | "dependencies": { 10 | "async": "^2.0.0-rc.2", 11 | "bcrypt": "^0.8.3", 12 | "passwordless-tokenstore": "0.0.10", 13 | "redis": "^2.5.3" 14 | }, 15 | "devDependencies": { 16 | "chai": "^3.5.0", 17 | "chance": "^1.0.1", 18 | "grunt": "^0.4.5", 19 | "grunt-mocha-test": "^0.12.7", 20 | "mocha": "^2.4.5", 21 | "node-uuid": "^1.4.7", 22 | "passwordless-tokenstore-test": "^0.1.4" 23 | }, 24 | "repository": { 25 | "type": "git", 26 | "url": "https://github.com/florianheinemann/passwordless-redisstore.git" 27 | }, 28 | "keywords": [ 29 | "redis", 30 | "passwordless", 31 | "token", 32 | "otpw", 33 | "one-time-password", 34 | "store", 35 | "tokenstore" 36 | ], 37 | "author": "Florian Heinemann (http://twitter.com/florian__h)", 38 | "license": "MIT", 39 | "bugs": { 40 | "url": "https://github.com/florianheinemann/passwordless-redisstore/issues" 41 | }, 42 | "homepage": "https://github.com/florianheinemann/passwordless-redisstore" 43 | } 44 | -------------------------------------------------------------------------------- /test/redisstore.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var expect = require('chai').expect; 4 | var uuid = require('node-uuid'); 5 | var chance = new require('chance')(); 6 | 7 | var RedisStore = require('../'); 8 | var TokenStore = require('passwordless-tokenstore'); 9 | 10 | var redis = require("redis"); 11 | var client = null; 12 | var db = 15; 13 | 14 | var standardTests = require('passwordless-tokenstore-test'); 15 | 16 | function TokenStoreFactory() { 17 | return new RedisStore(null, null, { redisstore: {database: db }} ); 18 | } 19 | 20 | var beforeEachTest = function(done) { 21 | if(!client) { 22 | client = redis.createClient(); 23 | } 24 | 25 | client.select(db, function(err, res) { 26 | done(err); 27 | }) 28 | } 29 | 30 | var afterEachTest = function(done) { 31 | client.flushdb(function(err, res) { 32 | done(err); 33 | }) 34 | } 35 | 36 | // Call all standard tests 37 | standardTests(TokenStoreFactory, beforeEachTest, afterEachTest); 38 | 39 | describe('Specific tests', function() { 40 | 41 | beforeEach(function(done) { 42 | beforeEachTest(done); 43 | }) 44 | 45 | afterEach(function(done) { 46 | afterEachTest(done); 47 | }) 48 | 49 | it('should allow the instantiation with an empty constructor', function () { 50 | expect(function() { new RedisStore() }).to.not.throw(); 51 | }) 52 | 53 | it('should allow the instantiation with host and port but no options', function () { 54 | expect(function() { new RedisStore(6379, '127.0.0.1') }).to.not.throw(); 55 | }) 56 | 57 | it('should allow the instantiation with a number passed as DB selector', function () { 58 | expect(function() { new RedisStore(null, null, {redisstore : { database: 0}}) }).to.not.throw(); 59 | }) 60 | 61 | it('should allow proper instantiation', function () { 62 | expect(function() { TokenStoreFactory() }).to.not.throw(); 63 | }) 64 | 65 | it('should not allow the instantiation with a DB selector that is not a number', function () { 66 | expect(function() { new RedisStore(null, null, {redisstore : { database: 'test'}}) }).to.throw(Error); 67 | }) 68 | 69 | it('should default to 0 as database', function(done) { 70 | var store = new RedisStore(); 71 | 72 | var user = chance.email(); 73 | store.storeOrUpdate(uuid.v4(), user, 74 | 1000*60, 'http://' + chance.domain() + '/page.html', 75 | function() { 76 | client.select(0, function(err, res) { 77 | expect(err).to.not.exist; 78 | client.hgetall('pwdless:' + user, function(err, obj) { 79 | expect(err).to.not.exist; 80 | expect(obj).to.exist; 81 | client.del('pwdless:' + user, function(err, dels) { 82 | expect(err).to.not.exist; 83 | expect(dels).to.equal(1); 84 | done(); 85 | }) 86 | }) 87 | }) 88 | }); 89 | }); 90 | 91 | it('should change name of token key based on "redisstore.tokenkey"', function(done) { 92 | var store = new RedisStore(null, null, { redisstore : { tokenkey: 'another_name_', database: db }}); 93 | 94 | var user = chance.email(); 95 | store.storeOrUpdate(uuid.v4(), user, 96 | 1000*60, 'http://' + chance.domain() + '/page.html', 97 | function() { 98 | client.hgetall('another_name_' + user, function(err, obj) { 99 | expect(err).to.not.exist; 100 | expect(obj).to.exist; 101 | done(); 102 | }) 103 | }); 104 | }); 105 | 106 | it('should default to "pwdless:" as token key', function(done) { 107 | var store = TokenStoreFactory(); 108 | var user = chance.email(); 109 | store.storeOrUpdate(uuid.v4(), user, 110 | 1000*60, 'http://' + chance.domain() + '/page.html', 111 | function() { 112 | client.hgetall('pwdless:' + user, function(err, obj) { 113 | expect(err).to.not.exist; 114 | expect(obj).to.exist; 115 | done(); 116 | }) 117 | }); 118 | }); 119 | 120 | it('should store tokens only in their hashed form', function(done) { 121 | var store = TokenStoreFactory(); 122 | var user = chance.email(); 123 | var token = uuid.v4(); 124 | store.storeOrUpdate(token, user, 125 | 1000*60, 'http://' + chance.domain() + '/page.html', 126 | function() { 127 | client.hgetall('pwdless:' + user, function(err, obj) { 128 | expect(err).to.not.exist; 129 | expect(obj).to.exist; 130 | expect(obj.token).to.exist; 131 | expect(obj.token).to.not.equal(token); 132 | done(); 133 | }) 134 | }); 135 | }); 136 | 137 | it('should store tokens not only hashed but also salted', function(done) { 138 | var store = TokenStoreFactory(); 139 | var user = chance.email(); 140 | var token = uuid.v4(); 141 | var hashedToken1; 142 | store.storeOrUpdate(token, user, 143 | 1000*60, 'http://' + chance.domain() + '/page.html', 144 | function() { 145 | client.hgetall('pwdless:' + user, function(err, obj) { 146 | expect(err).to.not.exist; 147 | expect(obj).to.exist; 148 | expect(obj.token).to.exist; 149 | hashedToken1 = obj.token; 150 | store.storeOrUpdate(token, user, 151 | 1000*60, 'http://' + chance.domain() + '/page.html', 152 | function() { 153 | client.hgetall('pwdless:' + user, function(err, obj) { 154 | expect(err).to.not.exist; 155 | expect(obj).to.exist; 156 | expect(obj.token).to.exist; 157 | expect(obj.token).to.not.equal(hashedToken1); 158 | done(); 159 | }); 160 | }); 161 | }) 162 | }); 163 | }); 164 | }) --------------------------------------------------------------------------------