diff --git a/Dockerfile b/Dockerfile index 00fcfc7f..72af1bb0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,4 +10,6 @@ COPY src /usr/src ENV PORT=80 EXPOSE 80 +VOLUME /var/lib/auth-server + CMD ["node", "index.js"] diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index bd439c02..00074863 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -3,6 +3,7 @@ version: '2' services: auth: volumes: + - ./test:/usr/src/test - ./src/views:/usr/src/views - ./src/public_html:/usr/src/public_html diff --git a/docker-compose.yml b/docker-compose.yml index d9ea01a7..1c18c832 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,6 +9,7 @@ services: - TOTP_SECRET=GRWGIJS6IRHVEODVNRCXCOBMJ5AGC6ZE - SESSION_SECRET=unsecure_secret - SESSION_EXPIRATION_TIME=3600000 + - STORE_DIRECTORY=/var/lib/auth-server depends_on: - ldap restart: always diff --git a/package.json b/package.json index e62f951e..ac5bb057 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "unit-test": "./node_modules/.bin/mocha --recursive test/unitary", "int-test": "./node_modules/.bin/mocha --recursive test/integration", "all-test": "./node_modules/.bin/mocha --recursive test", - "coverage": "./node_modules/.bin/istanbul cover _mocha -- -R spec" + "coverage": "./node_modules/.bin/istanbul cover _mocha -- -R spec --recursive test" }, "repository": { "type": "git", @@ -27,6 +27,7 @@ "express": "^4.14.0", "express-session": "^1.14.2", "ldapjs": "^1.0.1", + "nedb": "^1.8.0", "object-path": "^0.11.3", "speakeasy": "^2.0.0", "winston": "^2.3.1" @@ -36,6 +37,7 @@ "request": "^2.79.0", "should": "^11.1.1", "sinon": "^1.17.6", - "sinon-promise": "^0.1.3" + "sinon-promise": "^0.1.3", + "tmp": "0.0.31" } } diff --git a/src/index.js b/src/index.js index 6e164ccf..9ba2c02f 100644 --- a/src/index.js +++ b/src/index.js @@ -10,7 +10,8 @@ var config = { ldap_url: process.env.LDAP_URL || 'ldap://127.0.0.1:389', ldap_users_dn: process.env.LDAP_USERS_DN, session_secret: process.env.SESSION_SECRET, - session_max_age: process.env.SESSION_MAX_AGE || 3600000 // in ms + session_max_age: process.env.SESSION_MAX_AGE || 3600000, // in ms + store_directory: process.env.STORE_DIRECTORY } var ldap_client = ldap.createClient({ diff --git a/src/lib/routes/second_factor.js b/src/lib/routes/second_factor.js index 7b0933c6..1c5f72e4 100644 --- a/src/lib/routes/second_factor.js +++ b/src/lib/routes/second_factor.js @@ -1,8 +1,6 @@ -var user_key_container = {}; var denyNotLogged = require('./deny_not_logged'); -var u2f = require('./u2f')(user_key_container); // create a u2f handler bound to -// user key container +var u2f = require('./u2f'); module.exports = { totp: denyNotLogged(require('./totp')), diff --git a/src/lib/routes/u2f.js b/src/lib/routes/u2f.js index 941f7306..6cca442e 100644 --- a/src/lib/routes/u2f.js +++ b/src/lib/routes/u2f.js @@ -1,11 +1,9 @@ -module.exports = function(user_key_container) { - return { - register_request: register_request, - register: register(user_key_container), - sign_request: sign_request(user_key_container), - sign: sign(user_key_container), - } +module.exports = { + register_request: register_request, + register: register, + sign_request: sign_request, + sign: sign, } var objectPath = require('object-path'); @@ -26,15 +24,18 @@ function replyWithUnauthorized(res) { res.send(); } +function extractAppId(req) { + return util.format('https://%s', req.headers.host); +} function register_request(req, res) { var u2f = req.app.get('u2f'); var logger = req.app.get('logger'); - var app_id = util.format('https://%s', req.headers.host); + var appid = extractAppId(req); logger.debug('U2F register_request: headers=%s', JSON.stringify(req.headers)); logger.info('U2F register_request: Starting registration'); - u2f.startRegistration(app_id, []) + u2f.startRegistration(appid, []) .then(function(registrationRequest) { logger.info('U2F register_request: Sending back registration request'); req.session.auth_session.register_request = registrationRequest; @@ -46,99 +47,125 @@ function register_request(req, res) { }); } -function register(user_key_container) { - return function(req, res) { - if(!objectPath.has(req, 'session.auth_session.register_request')) { - replyWithUnauthorized(res); - return; - } - - var u2f = req.app.get('u2f'); - var registrationRequest = req.session.auth_session.register_request; - var logger = req.app.get('logger'); - - logger.info('U2F register: Finishing registration'); - logger.debug('U2F register: register_request=%s', JSON.stringify(registrationRequest)); - logger.debug('U2F register: body=%s', JSON.stringify(req.body)); - - u2f.finishRegistration(registrationRequest, req.body) - .then(function(registrationStatus) { - logger.info('U2F register: Store registration and reply'); - var meta = { - keyHandle: registrationStatus.keyHandle, - publicKey: registrationStatus.publicKey, - certificate: registrationStatus.certificate - } - user_key_container[req.session.auth_session.userid] = meta; - res.status(204); - res.send(); - }, function(err) { - logger.error('U2F register: %s', err); - replyWithInternalError(res, 'Unable to complete the registration'); - }); +function register(req, res) { + if(!objectPath.has(req, 'session.auth_session.register_request')) { + replyWithUnauthorized(res); + return; } -} -function userKeyExists(req, user_key_container) { - return req.session.auth_session.userid in user_key_container; -} + var user_data_storage = req.app.get('user data store'); + var u2f = req.app.get('u2f'); + var registrationRequest = req.session.auth_session.register_request; + var userid = req.session.auth_session.userid; + var appid = extractAppId(req); + var logger = req.app.get('logger'); + logger.info('U2F register: Finishing registration'); + logger.debug('U2F register: register_request=%s', JSON.stringify(registrationRequest)); + logger.debug('U2F register: body=%s', JSON.stringify(req.body)); - -function sign_request(user_key_container) { - return function(req, res) { - if(!userKeyExists(req, user_key_container)) { - replyWithMissingRegistration(res); - return; + u2f.finishRegistration(registrationRequest, req.body) + .then(function(registrationStatus) { + logger.info('U2F register: Store registration and reply'); + var meta = { + keyHandle: registrationStatus.keyHandle, + publicKey: registrationStatus.publicKey, + certificate: registrationStatus.certificate } - - var logger = req.app.get('logger'); - var u2f = req.app.get('u2f'); - var key = user_key_container[req.session.auth_session.userid]; - var app_id = util.format('https://%s', req.headers.host); + user_data_storage.set_u2f_meta(userid, appid, meta); + res.status(204); + res.send(); + }, function(err) { + logger.error('U2F register: %s', err); + replyWithInternalError(res, 'Unable to complete the registration'); + }); +} - logger.info('U2F sign_request: Start authentication'); - u2f.startAuthentication(app_id, [key]) +function retrieveU2fMeta(req, user_data_storage) { + var userid = req.session.auth_session.userid; + var appid = extractAppId(req); + return user_data_storage.get_u2f_meta(userid, appid); +} + +function startU2fAuthentication(u2f, appid, meta) { + return new Promise(function(resolve, reject) { + u2f.startAuthentication(appid, [meta]) .then(function(authRequest) { - logger.info('U2F sign_request: Store authentication request and reply'); - req.session.auth_session.sign_request = authRequest; - res.status(200); - res.json(authRequest); + resolve(authRequest); }, function(err) { - logger.info('U2F sign_request: %s', err); - replyWithUnauthorized(res); + reject(err); }); - } + }); } -function sign(user_key_container) { - return function(req, res) { - if(!userKeyExists(req, user_key_container)) { +function finishU2fAuthentication(u2f, authRequest, data, meta) { + return new Promise(function(resolve, reject) { + u2f.finishAuthentication(authRequest, data, [meta]) + .then(function(authenticationStatus) { + resolve(authenticationStatus); + }, function(err) { + reject(err); + }) + }); +} + +function sign_request(req, res) { + var logger = req.app.get('logger'); + var user_data_storage = req.app.get('user data store'); + + retrieveU2fMeta(req, user_data_storage) + .then(function(doc) { + if(!doc) { replyWithMissingRegistration(res); return; } - if(!objectPath.has(req, 'session.auth_session.sign_request')) { - replyWithUnauthorized(res); - return; - } - - var logger = req.app.get('logger'); + var u2f = req.app.get('u2f'); + var meta = doc.meta; + var appid = extractAppId(req); + logger.info('U2F sign_request: Start authentication'); + return startU2fAuthentication(u2f, appid, meta); + }) + .then(function(authRequest) { + logger.info('U2F sign_request: Store authentication request and reply'); + req.session.auth_session.sign_request = authRequest; + res.status(200); + res.json(authRequest); + }) + .catch(function(err) { + logger.info('U2F sign_request: %s', err); + replyWithUnauthorized(res); + }); +} + + +function sign(req, res) { + if(!objectPath.has(req, 'session.auth_session.sign_request')) { + replyWithUnauthorized(res); + return; + } + + var logger = req.app.get('logger'); + var user_data_storage = req.app.get('user data store'); + + retrieveU2fMeta(req, user_data_storage) + .then(function(doc) { + var appid = extractAppId(req); var u2f = req.app.get('u2f'); var authRequest = req.session.auth_session.sign_request; - var key = user_key_container[req.session.auth_session.userid]; - + var meta = doc.meta; logger.info('U2F sign: Finish authentication'); - u2f.finishAuthentication(authRequest, req.body, [key]) - .then(function(authenticationStatus) { - logger.info('U2F sign: Authentication successful'); - req.session.auth_session.second_factor = true; - res.status(204); - res.send(); - }, function(err) { - logger.error('U2F sign: %s', err); - res.status(401); - res.send(); - }); - } + return finishU2fAuthentication(u2f, authRequest, req.body, meta); + }) + .then(function(authenticationStatus) { + logger.info('U2F sign: Authentication successful'); + req.session.auth_session.second_factor = true; + res.status(204); + res.send(); + }, function(err) { + logger.error('U2F sign: %s', err); + res.status(401); + res.send(); + }); } + diff --git a/src/lib/server.js b/src/lib/server.js index 750c625f..3c01a297 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -11,10 +11,14 @@ var speakeasy = require('speakeasy'); var path = require('path'); var session = require('express-session'); var winston = require('winston'); +var DataStore = require('nedb'); +var UserDataStore = require('./user_data_store'); function run(config, ldap_client, u2f, fn) { var view_directory = path.resolve(__dirname, '../views'); var public_html_directory = path.resolve(__dirname, '../public_html'); + var datastore_options = {}; + datastore_options.directory = config.store_directory; var app = express(); app.use(express.static(public_html_directory)); @@ -41,6 +45,7 @@ function run(config, ldap_client, u2f, fn) { app.set('ldap client', ldap_client); app.set('totp engine', speakeasy); app.set('u2f', u2f); + app.set('user data store', new UserDataStore(DataStore, datastore_options)); app.set('config', config); app.get ('/login', routes.login); diff --git a/src/lib/user_data_store.js b/src/lib/user_data_store.js new file mode 100644 index 00000000..5c7b363f --- /dev/null +++ b/src/lib/user_data_store.js @@ -0,0 +1,39 @@ + +module.exports = UserDataStore; + +var Promise = require('bluebird'); +var path = require('path'); + +function UserDataStore(DataStore, options) { + var datastore_options = {}; + if(options.directory) + datastore_options.filename = path.resolve(options.directory, 'u2f_meta'); + + datastore_options.inMemoryOnly = options.inMemoryOnly || false; + datastore_options.autoload = true; + console.log(datastore_options); + + this._u2f_meta_collection = Promise.promisifyAll(new DataStore(datastore_options)); +} + +UserDataStore.prototype.set_u2f_meta = function(userid, app_id, meta) { + var newDocument = {}; + newDocument.userid = userid; + newDocument.appid = app_id; + newDocument.meta = meta; + + var filter = {}; + filter.userid = userid; + filter.appid = app_id; + + return this._u2f_meta_collection.updateAsync(filter, newDocument, { upsert: true }); +} + +UserDataStore.prototype.get_u2f_meta = function(userid, app_id) { + var filter = {}; + filter.userid = userid; + filter.appid = app_id; + + return this._u2f_meta_collection.findOneAsync(filter); +} + diff --git a/test/unitary/routes/test_u2f.js b/test/unitary/routes/test_u2f.js index 09b01ee1..1c26c1cb 100644 --- a/test/unitary/routes/test_u2f.js +++ b/test/unitary/routes/test_u2f.js @@ -7,6 +7,7 @@ var winston = require('winston'); describe('test u2f routes', function() { var req, res; + var user_data_store; beforeEach(function() { req = {} @@ -21,8 +22,17 @@ describe('test u2f routes', function() { req.headers = {}; req.headers.host = 'localhost'; + var options = {}; + options.inMemoryOnly = true; + + user_data_store = {}; + user_data_store.set_u2f_meta = sinon.stub().returns(Promise.resolve({})); + user_data_store.get_u2f_meta = sinon.stub().returns(Promise.resolve({})); + req.app.get.withArgs('user data store').returns(user_data_store); + res = {}; res.send = sinon.spy(); + res.json = sinon.spy(); res.status = sinon.spy(); }) @@ -31,8 +41,6 @@ describe('test u2f routes', function() { describe('test signing request', test_signing_request); describe('test signing', test_signing); - - function test_registration_request() { it('should send back the registration request and save it in the session', function(done) { var expectedRequest = { @@ -49,7 +57,7 @@ describe('test u2f routes', function() { u2f_mock.startRegistration.returns(Promise.resolve(expectedRequest)); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register_request(req, res); + u2f.register_request(req, res); }); it('should return internal error on registration request', function(done) { @@ -63,21 +71,19 @@ describe('test u2f routes', function() { u2f_mock.startRegistration.returns(Promise.reject('Internal error')); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register_request(req, res); + u2f.register_request(req, res); }); } function test_registration() { - it('should return status code 200', function(done) { - var user_key_container = {}; + it('should save u2f meta and return status code 200', function(done) { var expectedStatus = { keyHandle: 'keyHandle', publicKey: 'pbk', certificate: 'cert' }; res.send = sinon.spy(function(data) { - assert('user' in user_key_container); - assert.deepEqual(expectedStatus, user_key_container['user']); + assert('user', user_data_store.set_u2f_meta.getCall(0).args[0]) done(); }); var u2f_mock = {}; @@ -86,7 +92,7 @@ describe('test u2f routes', function() { req.session.auth_session.register_request = {}; req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register(req, res); + u2f.register(req, res); }); it('should return unauthorized error on registration request', function(done) { @@ -100,7 +106,7 @@ describe('test u2f routes', function() { u2f_mock.finishRegistration.returns(Promise.reject('Internal error')); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register(req, res); + u2f.register(req, res); }); it('should return unauthorized error when no auth request has been initiated', function(done) { @@ -114,7 +120,7 @@ describe('test u2f routes', function() { u2f_mock.finishRegistration.returns(Promise.resolve()); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register(req, res); + u2f.register(req, res); }); } @@ -136,7 +142,7 @@ describe('test u2f routes', function() { u2f_mock.startAuthentication.returns(Promise.resolve(expectedRequest)); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).sign_request(req, res); + u2f.sign_request(req, res); }); it('should return unauthorized error on registration request error', function(done) { @@ -151,7 +157,7 @@ describe('test u2f routes', function() { u2f_mock.startAuthentication.returns(Promise.reject('Internal error')); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).sign_request(req, res); + u2f.sign_request(req, res); }); it('should send unauthorized error when no registration exists', function(done) { @@ -167,8 +173,13 @@ describe('test u2f routes', function() { u2f_mock.startAuthentication = sinon.stub(); u2f_mock.startAuthentication.returns(Promise.resolve(expectedRequest)); + user_data_store.get_u2f_meta = sinon.stub().returns(Promise.resolve()); + + req.app.get = sinon.stub(); + req.app.get.withArgs('logger').returns(winston); + req.app.get.withArgs('user data store').returns(user_data_store); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).sign_request(req, res); + u2f.sign_request(req, res); }); } @@ -192,7 +203,7 @@ describe('test u2f routes', function() { req.session.auth_session.sign_request = {}; req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).sign(req, res); + u2f.sign(req, res); }); it('should return unauthorized error on registration request internal error', function(done) { @@ -209,7 +220,7 @@ describe('test u2f routes', function() { req.session.auth_session.sign_request = {}; req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register(req, res); + u2f.register(req, res); }); it('should return unauthorized error when no sign request has been initiated', function(done) { @@ -223,7 +234,7 @@ describe('test u2f routes', function() { u2f_mock.finishAuthentication.returns(Promise.resolve()); req.app.get.withArgs('u2f').returns(u2f_mock); - u2f(user_key_container).register(req, res); + u2f.register(req, res); }); } }); diff --git a/test/unitary/test_data_persistence.js b/test/unitary/test_data_persistence.js new file mode 100644 index 00000000..774d28fd --- /dev/null +++ b/test/unitary/test_data_persistence.js @@ -0,0 +1,169 @@ + +var server = require('../../src/lib/server'); + +var request = require('request'); +var assert = require('assert'); +var speakeasy = require('speakeasy'); +var sinon = require('sinon'); +var Promise = require('bluebird'); +var tmp = require('tmp'); + +var request = Promise.promisifyAll(request); + +var PORT = 8050; +var BASE_URL = 'http://localhost:' + PORT; + +describe('test data persistence', function() { + var u2f; + var tmpDir; + var ldap_client = { + bind: sinon.stub() + }; + var config; + + before(function() { + u2f = {}; + u2f.startRegistration = sinon.stub(); + u2f.finishRegistration = sinon.stub(); + u2f.startAuthentication = sinon.stub(); + u2f.finishAuthentication = sinon.stub(); + + ldap_client.bind.withArgs('cn=test_ok,ou=users,dc=example,dc=com', + 'password').yields(undefined); + ldap_client.bind.withArgs('cn=test_nok,ou=users,dc=example,dc=com', + 'password').yields('error'); + tmpDir = tmp.dirSync({ unsafeCleanup: true }); + config = { + port: PORT, + totp_secret: 'totp_secret', + ldap_url: 'ldap://127.0.0.1:389', + ldap_users_dn: 'ou=users,dc=example,dc=com', + session_secret: 'session_secret', + session_max_age: 50000, + store_directory: tmpDir.name + }; + }); + + after(function() { + tmpDir.removeCallback(); + }); + + it('should save a u2f meta and reload it after a restart of the server', function() { + var server; + var sign_request = {}; + var sign_status = {}; + var registration_request = {}; + var registration_status = {}; + u2f.startRegistration.returns(Promise.resolve(sign_request)); + u2f.finishRegistration.returns(Promise.resolve(sign_status)); + u2f.startAuthentication.returns(Promise.resolve(registration_request)); + u2f.finishAuthentication.returns(Promise.resolve(registration_status)); + + var j1 = request.jar(); + var j2 = request.jar(); + return start_server(config, ldap_client, u2f) + .then(function(s) { + server = s; + return execute_login(j1); + }) + .then(function(res) { + return execute_first_factor(j1); + }) + .then(function() { + return execute_u2f_registration(j1); + }) + .then(function() { + return execute_u2f_authentication(j1); + }) + .then(function() { + return stop_server(server); + }) + .then(function() { + return start_server(config, ldap_client, u2f) + }) + .then(function(s) { + server = s; + return execute_login(j2); + }) + .then(function() { + return execute_first_factor(j2); + }) + .then(function() { + return execute_u2f_authentication(j2); + }) + .then(function(res) { + assert.equal(204, res.statusCode); + server.close(); + return Promise.resolve(); + }) + .catch(function(err) { + console.error(err); + return Promise.reject(err); + }); + }); + + function start_server(config, ldap_client, u2f) { + return new Promise(function(resolve, reject) { + var s = server.run(config, ldap_client, u2f); + resolve(s); + }); + } + + function stop_server(s) { + return new Promise(function(resolve, reject) { + s.close(); + resolve(); + }); + } + + function execute_first_factor(jar) { + return request.postAsync({ + url: BASE_URL + '/_auth/1stfactor', + jar: jar, + form: { + username: 'test_ok', + password: 'password' + } + }); + } + + function execute_u2f_registration(jar) { + return request.getAsync({ + url: BASE_URL + '/_auth/2ndfactor/u2f/register_request', + jar: jar + }) + .then(function(res) { + return request.postAsync({ + url: BASE_URL + '/_auth/2ndfactor/u2f/register', + jar: jar, + form: { + s: 'test' + } + }); + }); + } + + function execute_u2f_authentication(jar) { + return request.getAsync({ + url: BASE_URL + '/_auth/2ndfactor/u2f/sign_request', + jar: jar + }) + .then(function() { + return request.postAsync({ + url: BASE_URL + '/_auth/2ndfactor/u2f/sign', + jar: jar, + form: { + s: 'test' + } + }); + }); + } + + function execute_verification(jar) { + return request.getAsync({ url: BASE_URL + '/_verify', jar: jarĀ }) + } + + function execute_login(jar) { + return request.getAsync({ url: BASE_URL + '/login', jar: jar }) + } +}); diff --git a/test/unitary/test_user_data_store.js b/test/unitary/test_user_data_store.js new file mode 100644 index 00000000..f46b9941 --- /dev/null +++ b/test/unitary/test_user_data_store.js @@ -0,0 +1,68 @@ + +var UserDataStore = require('../../src/lib/user_data_store'); +var DataStore = require('nedb'); +var assert = require('assert'); +var Promise = require('bluebird'); + +describe('test user data store', function() { + it('should save a u2f meta', function() { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var userid = 'user'; + var app_id = 'https://localhost'; + var meta = {}; + meta.publicKey = 'pbk'; + + return data_store.set_u2f_meta(userid, app_id, meta) + .then(function(numUpdated) { + assert.equal(1, numUpdated); + return Promise.resolve(); + }); + }); + + it('should retrieve no u2f meta', function() { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var userid = 'user'; + var app_id = 'https://localhost'; + var meta = {}; + meta.publicKey = 'pbk'; + + return data_store.get_u2f_meta(userid, app_id) + .then(function(doc) { + assert.equal(undefined, doc); + return Promise.resolve(); + }); + }); + + it('should insert and retrieve a u2f meta', function() { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var userid = 'user'; + var app_id = 'https://localhost'; + var meta = {}; + meta.publicKey = 'pbk'; + + return data_store.set_u2f_meta(userid, app_id, meta) + .then(function(numUpdated, data) { + assert.equal(1, numUpdated); + return data_store.get_u2f_meta(userid, app_id) + }) + .then(function(doc) { + assert.deepEqual(meta, doc.meta); + assert.deepEqual(userid, doc.userid); + assert.deepEqual(app_id, doc.appid); + assert('_id' in doc); + return Promise.resolve(); + }); + }); +});