From 05046338ed9ca872f8fc78d5a396f7da593cac39 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Fri, 27 Jan 2017 01:20:03 +0100 Subject: [PATCH] Implement password reset --- config.template.yml | 2 + example/nginx_conf/index.html | 4 +- example/nginx_conf/nginx.conf | 20 +- package.json | 2 + src/index.js | 14 +- src/lib/exceptions.js | 14 ++ src/lib/identity_check.js | 141 ++++++++++++ src/lib/ldap.js | 30 ++- src/lib/routes.js | 7 +- src/lib/routes/first_factor.js | 21 +- src/lib/routes/reset_password.js | 72 ++++++ src/lib/routes/u2f.js | 39 +--- src/lib/routes/u2f_register.js | 24 +- src/lib/routes/u2f_register_handler.js | 101 ++------- src/lib/routes/verify.js | 2 - src/lib/server.js | 40 ++-- src/lib/user_data_store.js | 40 ++-- src/public_html/{ => css}/login.css | 4 +- src/public_html/{ => js}/jquery.min.js | 0 src/public_html/{ => js}/login.js | 21 +- src/public_html/{ => js}/notify.min.js | 0 src/public_html/js/reset-password-form.js | 47 ++++ src/public_html/js/reset-password.js | 51 +++++ src/public_html/{ => js}/u2f-api.js | 0 src/public_html/{ => js}/u2f-register.js | 5 +- src/views/head.ejs | 1 + src/views/login.ejs | 12 +- src/views/reset-password-form.ejs | 18 ++ src/views/reset-password.ejs | 19 ++ src/views/scripts.ejs | 2 + .../{u2f_register.ejs => u2f-register.ejs} | 9 +- test/unitary/requests.js | 130 +++++++++++ test/unitary/routes/test_reset_password.js | 140 ++++++++++++ test/unitary/routes/test_u2f.js | 32 ++- test/unitary/routes/test_u2f_register.js | 85 ++----- test/unitary/test_data_persistence.js | 99 +++------ test/unitary/test_identity_check.js | 208 ++++++++++++++++++ test/unitary/test_ldap.js | 53 ++++- test/unitary/test_server.js | 177 ++++++++------- test/unitary/test_user_data_store.js | 49 ++++- 40 files changed, 1308 insertions(+), 427 deletions(-) create mode 100644 src/lib/identity_check.js create mode 100644 src/lib/routes/reset_password.js rename src/public_html/{ => css}/login.css (98%) rename src/public_html/{ => js}/jquery.min.js (100%) rename src/public_html/{ => js}/login.js (89%) rename src/public_html/{ => js}/notify.min.js (100%) create mode 100644 src/public_html/js/reset-password-form.js create mode 100644 src/public_html/js/reset-password.js rename src/public_html/{ => js}/u2f-api.js (100%) rename src/public_html/{ => js}/u2f-register.js (88%) create mode 100644 src/views/head.ejs create mode 100644 src/views/reset-password-form.ejs create mode 100644 src/views/reset-password.ejs create mode 100644 src/views/scripts.ejs rename src/views/{u2f_register.ejs => u2f-register.ejs} (50%) create mode 100644 test/unitary/requests.js create mode 100644 test/unitary/routes/test_reset_password.js create mode 100644 test/unitary/test_identity_check.js diff --git a/config.template.yml b/config.template.yml index 5b2097584..fd6a5a347 100644 --- a/config.template.yml +++ b/config.template.yml @@ -4,6 +4,8 @@ debug_level: info ldap: url: ldap://ldap base_dn: ou=users,dc=example,dc=com + user: cn=admin,dc=example,dc=com + password: password # Will be per user soon totp_secret: GRWGIJS6IRHVEODVNRCXCOBMJ5AGC6ZE diff --git a/example/nginx_conf/index.html b/example/nginx_conf/index.html index 939580829..94ff081e4 100644 --- a/example/nginx_conf/index.html +++ b/example/nginx_conf/index.html @@ -3,7 +3,7 @@ Home page - You need to log in to access the secret!

- You can also log off by visiting the following link. + You need to log in to access the secret!

+ You can also log off by visiting the following link. diff --git a/example/nginx_conf/nginx.conf b/example/nginx_conf/nginx.conf index 1541fdfac..4919f43a8 100644 --- a/example/nginx_conf/nginx.conf +++ b/example/nginx_conf/nginx.conf @@ -34,19 +34,31 @@ http { error_page 401 = @error401; location @error401 { - return 302 https://localhost:8080/auth/login?redirect=$request_uri; + return 302 https://localhost:8080/authentication/login?redirect=$request_uri; } - location /auth/ { + location /authentication/ { proxy_set_header X-Original-URI $request_uri; proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; - proxy_pass http://auth/; + proxy_pass http://auth/authentication/; + } + + location /authentication/js/ { + proxy_pass http://auth/js/; + } + + location /authentication/img/ { + proxy_pass http://auth/img/; + } + + location /authentication/css/ { + proxy_pass http://auth/css/; } location = /secret.html { - auth_request /auth/verify; + auth_request /authentication/verify; auth_request_set $user $upstream_http_x_remote_user; proxy_set_header X-Forwarded-User $user; diff --git a/package.json b/package.json index bb9763798..5f071b81a 100644 --- a/package.json +++ b/package.json @@ -23,9 +23,11 @@ "authdog": "^0.1.1", "bluebird": "^3.4.7", "body-parser": "^1.15.2", + "dovehash": "0.0.5", "ejs": "^2.5.5", "express": "^4.14.0", "express-session": "^1.14.2", + "jshashes": "^1.0.6", "ldapjs": "^1.0.1", "nedb": "^1.8.0", "nodemailer": "^2.7.0", diff --git a/src/index.js b/src/index.js index ddc2d9a75..14edc7281 100644 --- a/src/index.js +++ b/src/index.js @@ -1,8 +1,12 @@ +process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; + var server = require('./lib/server'); var ldap = require('ldapjs'); var u2f = require('authdog'); +var nodemailer = require('nodemailer'); +var nedb = require('nedb'); var YAML = require('yamljs'); var config_path = process.argv[2]; @@ -15,6 +19,8 @@ var config = { totp_secret: yaml_config.totp_secret, ldap_url: yaml_config.ldap.url || 'ldap://127.0.0.1:389', ldap_users_dn: yaml_config.ldap.base_dn, + ldap_user: yaml_config.ldap.user, + ldap_password: yaml_config.ldap.password, session_secret: yaml_config.session.secret, session_max_age: yaml_config.session.expiration || 3600000, // in ms store_directory: yaml_config.store_directory, @@ -30,4 +36,10 @@ var ldap_client = ldap.createClient({ reconnect: true }); -server.run(config, ldap_client, u2f); +var deps = {}; +deps.u2f = u2f; +deps.nedb = nedb; +deps.nodemailer = nodemailer; +deps.ldap = ldap; + +server.run(config, ldap_client, deps); diff --git a/src/lib/exceptions.js b/src/lib/exceptions.js index 7b7a1d98d..86f2e20a5 100644 --- a/src/lib/exceptions.js +++ b/src/lib/exceptions.js @@ -2,6 +2,8 @@ module.exports = { LdapSearchError: LdapSearchError, LdapBindError: LdapBindError, + IdentityError: IdentityError, + AccessDeniedError: AccessDeniedError } function LdapSearchError(message) { @@ -15,3 +17,15 @@ function LdapBindError(message) { this.message = (message || ""); } LdapBindError.prototype = Object.create(Error.prototype); + +function IdentityError(message) { + this.name = "IdentityError"; + this.message = (message || ""); +} +IdentityError.prototype = Object.create(Error.prototype); + +function AccessDeniedError(message) { + this.name = "AccessDeniedError"; + this.message = (message || ""); +} +AccessDeniedError.prototype = Object.create(Error.prototype); diff --git a/src/lib/identity_check.js b/src/lib/identity_check.js new file mode 100644 index 000000000..076aab2cc --- /dev/null +++ b/src/lib/identity_check.js @@ -0,0 +1,141 @@ + +var objectPath = require('object-path'); +var randomstring = require('randomstring'); +var Promise = require('bluebird'); +var util = require('util'); +var exceptions = require('./exceptions'); + +module.exports = identity_check; + + +// IdentityCheck class + +function IdentityCheck(user_data_store, email_sender, logger) { + this._user_data_store = user_data_store; + this._email_sender = email_sender; + this._logger = logger; +} + +IdentityCheck.prototype.issue_token = function(userid, email, content, logger) { + var five_minutes = 4 * 60 * 1000; + var token = randomstring.generate({ length: 64 }); + var that = this; + + this._logger.debug('identity_check: issue identity token %s for 5 minutes', token); + return this._user_data_store.issue_identity_check_token(userid, token, content, five_minutes) + .then(function() { + that._logger.debug('identity_check: send email to %s', email); + return that._send_identity_check_email(email, token); + }) +} + +IdentityCheck.prototype._send_identity_check_email = function(email, token) { + var url = util.format('%s?identity_token=%s', email.hook_url, token); + var email_content = util.format('Register', url); + return this._email_sender.send(email.to, email.subject, email_content); +} + +IdentityCheck.prototype.consume_token = function(token, logger) { + this._logger.debug('identity_check: consume token %s', token); + return this._user_data_store.consume_identity_check_token(token) +} + + +// The identity_check middleware that allows the user two perform a two step validation +// using the user email + +function identity_check(app, endpoint, icheck_interface) { + app.get(endpoint, identity_check_get(endpoint, icheck_interface)); + app.post(endpoint, identity_check_post(endpoint, icheck_interface)); +} + + +function identity_check_get(endpoint, icheck_interface) { + return function(req, res) { + var logger = req.app.get('logger'); + var identity_token = objectPath.get(req, 'query.identity_token'); + logger.info('GET identity_check: identity token provided is %s', identity_token); + + if(!identity_token) { + res.status(403); + res.send(); + return; + } + + var email_sender = req.app.get('email sender'); + var user_data_store = req.app.get('user data store'); + var identity_check = new IdentityCheck(user_data_store, email_sender, logger); + + identity_check.consume_token(identity_token, logger) + .then(function(content) { + objectPath.set(req, 'session.auth_session.identity_check', {}); + req.session.auth_session.identity_check.challenge = icheck_interface.challenge; + req.session.auth_session.identity_check.userid = content.userid; + res.render(icheck_interface.render_template); + }, function(err) { + logger.error('GET identity_check: Error while consuming token %s', err); + throw new exceptions.AccessDeniedError('Access denied'); + }) + .catch(exceptions.AccessDeniedError, function(err) { + logger.error('GET identity_check: Access Denied %s', err); + res.status(403); + res.send(); +  }) + .catch(function(err) { + logger.error('GET identity_check: Internal error %s', err); + res.status(500); + res.send(); + }); + } +} + + +function identity_check_post(endpoint, icheck_interface) { + return function(req, res) { + var logger = req.app.get('logger'); + var email_sender = req.app.get('email sender'); + var user_data_store = req.app.get('user data store'); + var identity_check = new IdentityCheck(user_data_store, email_sender, logger); + var userid, email_address; + + icheck_interface.pre_check_callback(req) + .then(function(identity) { + email_address = objectPath.get(identity, 'email'); + userid = objectPath.get(identity, 'userid'); + if(!(email_address && userid)) { + throw new exceptions.IdentityError('Missing user id or email address'); + } + + var email = {}; + email.to = email_address; + email.subject = 'Identity Verification'; + email.hook_url = util.format('https://%s%s', req.headers.host, req.headers['x-original-uri']); + return identity_check.issue_token(userid, email, undefined, logger); + }, function(err) { + throw new exceptions.AccessDeniedError('Access denied'); + }) + .then(function() { + res.status(204); + res.send(); + }) + .catch(exceptions.IdentityError, function(err) { + logger.error('POST identity_check: %s', err); + res.status(400); + res.send(); + return; + }) + .catch(exceptions.AccessDeniedError, function(err) { + logger.error('POST identity_check: %s', err); + res.status(403); + res.send(); + return; + }) + .catch(function(err) { + logger.error('POST identity_check: %s', err); + res.status(500); + res.send(); + }); + } +} + + diff --git a/src/lib/ldap.js b/src/lib/ldap.js index b267cfe98..4b30e444b 100644 --- a/src/lib/ldap.js +++ b/src/lib/ldap.js @@ -1,18 +1,23 @@ module.exports = { validate: validateCredentials, - get_email: retrieve_email + get_email: retrieve_email, + update_password: update_password } var util = require('util'); var Promise = require('bluebird'); var exceptions = require('./exceptions'); +var Hashes = require('jshashes') +var Dovehash = require('dovehash'); function validateCredentials(ldap_client, username, password, users_dn) { var userDN = util.format("cn=%s,%s", username, users_dn); var bind_promised = Promise.promisify(ldap_client.bind, { context: ldap_client }); + console.log(username, password); return bind_promised(userDN, password) .error(function(err) { + console.error(err); throw new exceptions.LdapBindError(err.message); }); } @@ -33,14 +38,33 @@ function retrieve_email(ldap_client, username, users_dn) { doc = entry.object; }); res.on('error', function(err) { - reject(new exceptions.LdapSearchError(err.message)); + reject(new exceptions.LdapSearchError(err)); }); res.on('end', function(result) { resolve(doc); }); }) .catch(function(err) { - reject(new exceptions.LdapSearchError(err.message)); + reject(new exceptions.LdapSearchError(err)); }); }); } + +function update_password(ldap_client, ldap, username, new_password, config) { + var userDN = util.format("cn=%s,%s", username, config.ldap_users_dn); + var encoded_password = Dovehash.encode('SSHA', new_password); + var change = new ldap.Change({ + operation: 'replace', + modification: { + userPassword: encoded_password + } + }); + + var modify_promised = Promise.promisify(ldap_client.modify, { context: ldap_client }); + var bind_promised = Promise.promisify(ldap_client.bind, { context: ldap_client }); + + return bind_promised(config.ldap_user, config.ldap_password) + .then(function() { + return modify_promised(userDN, change); + }); +} diff --git a/src/lib/routes.js b/src/lib/routes.js index b74de5db0..325bdfbe4 100644 --- a/src/lib/routes.js +++ b/src/lib/routes.js @@ -1,7 +1,9 @@ var first_factor = require('./routes/first_factor'); var second_factor = require('./routes/second_factor'); +var reset_password = require('./routes/reset_password'); var verify = require('./routes/verify'); +var u2f_register_handler = require('./routes/u2f_register_handler'); var objectPath = require('object-path'); module.exports = { @@ -9,7 +11,9 @@ module.exports = { logout: serveLogout, verify: verify, first_factor: first_factor, - second_factor: second_factor + second_factor: second_factor, + reset_password: reset_password, + u2f_register: u2f_register_handler } function serveLogin(req, res) { @@ -18,7 +22,6 @@ function serveLogin(req, res) { req.session.auth_session.first_factor = false; req.session.auth_session.second_factor = false; } - res.render('login'); } diff --git a/src/lib/routes/first_factor.js b/src/lib/routes/first_factor.js index 1551be6cd..3c2b49f85 100644 --- a/src/lib/routes/first_factor.js +++ b/src/lib/routes/first_factor.js @@ -12,15 +12,8 @@ function replyWithUnauthorized(res) { function first_factor(req, res) { var logger = req.app.get('logger'); - if(!objectPath.has(req, 'session.auth_session.second_factor')) { - logger.error('1st factor: Session is missing.'); - replyWithUnauthorized(res); - } - var username = req.body.username; var password = req.body.password; - console.log('Start authentication of user %s', username); - if(!username || !password) { replyWithUnauthorized(res); return; @@ -34,29 +27,31 @@ function first_factor(req, res) { logger.debug('1st factor: Start bind operation against LDAP'); logger.debug('1st factor: username=%s', username); logger.debug('1st factor: base_dn=%s', config.ldap_users_dn); + ldap.validate(ldap_client, username, password, config.ldap_users_dn) .then(function() { - req.session.auth_session.userid = username; - req.session.auth_session.first_factor = true; + objectPath.set(req, 'session.auth_session.userid', username); + objectPath.set(req, 'session.auth_session.first_factor', true); logger.info('1st factor: LDAP binding successful'); logger.debug('1st factor: Retrieve email from LDAP'); return ldap.get_email(ldap_client, username, config.ldap_users_dn) }) .then(function(doc) { - logger.debug('1st factor: document=%s', JSON.stringify(doc)); var email = objectPath.get(doc, 'mail'); + logger.debug('1st factor: document=%s', JSON.stringify(doc)); logger.debug('1st factor: Retrieved email is %s', email); - req.session.auth_session.email = email; + + objectPath.set(req, 'session.auth_session.email', email); res.status(204); res.send(); }) .catch(exceptions.LdapSearchError, function(err) { - logger.info('1st factor: Unable to retrieve email from LDAP'); + logger.info('1st factor: Unable to retrieve email from LDAP', err); res.status(500); res.send(); }) .catch(exceptions.LdapBindError, function(err) { - logger.info('1st factor: LDAP binding failed'); + logger.info('1st factor: LDAP binding failed', err); replyWithUnauthorized(res); }) .catch(function(err) { diff --git a/src/lib/routes/reset_password.js b/src/lib/routes/reset_password.js new file mode 100644 index 000000000..9eef18dc6 --- /dev/null +++ b/src/lib/routes/reset_password.js @@ -0,0 +1,72 @@ + +var objectPath = require('object-path'); +var ldap = require('../ldap'); +var CHALLENGE = 'reset-password'; + +var icheck_interface = { + challenge: CHALLENGE, + render_template: 'reset-password', + pre_check_callback: pre_check, +} + +module.exports = { + icheck_interface: icheck_interface, + post: protect(post) +} + +function pre_check(req) { + var userid = objectPath.get(req, 'body.userid'); + if(!userid) { + return Promise.reject('No user id provided'); + } + + var ldap_client = req.app.get('ldap client'); + var config = req.app.get('config'); + + return ldap.get_email(ldap_client, userid, config.ldap_users_dn) + .then(function(doc) { + var email = objectPath.get(doc, 'mail'); + + var identity = {} + identity.email = email; + identity.userid = userid; + return Promise.resolve(identity); + }) +} + +function protect(fn) { + return function(req, res) { + var challenge = objectPath.get(req, 'session.auth_session.identity_check.challenge'); + if(challenge != CHALLENGE) { + res.status(403); + res.send(); + return; + } + fn(req, res); +  } +} + +function post(req, res) { + var logger = req.app.get('logger'); + var ldapjs = req.app.get('ldap'); + var ldap_client = req.app.get('ldap client'); + var new_password = objectPath.get(req, 'body.password'); + var userid = objectPath.get(req, 'session.auth_session.identity_check.userid'); + var config = req.app.get('config'); + + logger.info('POST reset-password: User %s wants to reset his/her password', userid); + + ldap.update_password(ldap_client, ldapjs, userid, new_password, config) + .then(function() { + logger.info('POST reset-password: Password reset for user %s', userid); + objectPath.set(req, 'session.auth_session', {}); + res.status(204); + res.send(); + }) + .catch(function(err) { + logger.error('POST reset-password: Error while resetting the password of user %s. %s', userid, err); + res.status(500); + res.send(); + }); +} + diff --git a/src/lib/routes/u2f.js b/src/lib/routes/u2f.js index 33f423d73..15fb56417 100644 --- a/src/lib/routes/u2f.js +++ b/src/lib/routes/u2f.js @@ -1,6 +1,7 @@ var u2f_register = require('./u2f_register'); var u2f_common = require('./u2f_common'); +var objectPath = require('object-path'); module.exports = { register_request: u2f_register.register_request, @@ -12,41 +13,18 @@ module.exports = { sign: sign, } -var objectPath = require('object-path'); -function retrieveU2fMeta(req, user_data_storage) { +function retrieve_u2f_meta(req, user_data_storage) { var userid = req.session.auth_session.userid; var appid = u2f_common.extract_app_id(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) { - resolve(authRequest); - }, function(err) { - reject(err); - }); - }); -} - -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) + retrieve_u2f_meta(req, user_data_storage) .then(function(doc) { if(!doc) { u2f_common.reply_with_missing_registration(res); @@ -57,7 +35,7 @@ function sign_request(req, res) { var meta = doc.meta; var appid = u2f_common.extract_app_id(req); logger.info('U2F sign_request: Start authentication'); - return startU2fAuthentication(u2f, appid, meta); + return u2f.startAuthentication(appid, [meta]) }) .then(function(authRequest) { logger.info('U2F sign_request: Store authentication request and reply'); @@ -67,7 +45,8 @@ function sign_request(req, res) { }) .catch(function(err) { logger.info('U2F sign_request: %s', err); - u2f_common.reply_with_unauthorized(res); + res.status(500); + res.send(); }); } @@ -81,14 +60,14 @@ function sign(req, res) { var logger = req.app.get('logger'); var user_data_storage = req.app.get('user data store'); - retrieveU2fMeta(req, user_data_storage) + retrieve_u2f_meta(req, user_data_storage) .then(function(doc) { var appid = u2f_common.extract_app_id(req); var u2f = req.app.get('u2f'); var authRequest = req.session.auth_session.sign_request; var meta = doc.meta; logger.info('U2F sign: Finish authentication'); - return finishU2fAuthentication(u2f, authRequest, req.body, meta); + return u2f.finishAuthentication(authRequest, req.body, [meta]) }) .then(function(authenticationStatus) { logger.info('U2F sign: Authentication successful'); @@ -98,7 +77,7 @@ function sign(req, res) { }) .catch(function(err) { logger.error('U2F sign: %s', err); - res.status(401); + res.status(500); res.send(); }); } diff --git a/src/lib/routes/u2f_register.js b/src/lib/routes/u2f_register.js index 50785232a..a6d0610bf 100644 --- a/src/lib/routes/u2f_register.js +++ b/src/lib/routes/u2f_register.js @@ -13,8 +13,15 @@ var u2f_common = require('./u2f_common'); var Promise = require('bluebird'); function register_request(req, res) { - var u2f = req.app.get('u2f'); var logger = req.app.get('logger'); + var challenge = objectPath.get(req, 'session.auth_session.identity_check.challenge'); + if(challenge != 'u2f-register') { + res.status(403); + res.send(); + return; + } + + var u2f = req.app.get('u2f'); var appid = u2f_common.extract_app_id(req); logger.debug('U2F register_request: headers=%s', JSON.stringify(req.headers)); @@ -28,19 +35,23 @@ function register_request(req, res) { }) .catch(function(err) { logger.error('U2F register_request: %s', err); - u2f_common.reply_with_internal_error(res, 'Unable to complete the registration'); + res.status(500); + res.send('Unable to start registration request'); }); } function register(req, res) { - if(!objectPath.has(req, 'session.auth_session.register_request')) { - u2f_common.reply_with_unauthorized(res); + var registrationRequest = objectPath.get(req, 'session.auth_session.register_request'); + var challenge = objectPath.get(req, 'session.auth_session.identity_check.challenge'); + + if(!(registrationRequest && challenge == 'u2f-register')) { + res.status(403); + res.send(); return; } 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 = u2f_common.extract_app_id(req); var logger = req.app.get('logger'); @@ -65,7 +76,8 @@ function register(req, res) { }) .catch(function(err) { logger.error('U2F register: %s', err); - u2f_common.reply_with_unauthorized(res); + res.status(500); + res.send('Unable to register'); }); } diff --git a/src/lib/routes/u2f_register_handler.js b/src/lib/routes/u2f_register_handler.js index a60c550c3..0ecac0e3f 100644 --- a/src/lib/routes/u2f_register_handler.js +++ b/src/lib/routes/u2f_register_handler.js @@ -1,93 +1,36 @@ -module.exports = { - get: register_handler_get, - post: register_handler_post -} - var objectPath = require('object-path'); -var randomstring = require('randomstring'); var Promise = require('bluebird'); -var util = require('util'); -var u2f_common = require('./u2f_common'); +var CHALLENGE = 'u2f-register'; -function register_handler_get(req, res) { - var logger = req.app.get('logger'); - logger.info('U2F register_handler: Continue registration process'); +var icheck_interface = { + challenge: CHALLENGE, + render_template: 'u2f-register', + pre_check_callback: pre_check, +} - var registration_token = objectPath.get(req, 'query.registration_token'); - logger.debug('U2F register_handler: registration_token=%s', registration_token); +module.exports = { + icheck_interface: icheck_interface, +} - if(!registration_token) { - res.status(403); - res.send(); - return; + +function pre_check(req) { + var first_factor_passed = objectPath.get(req, 'session.auth_session.first_factor'); + if(!first_factor_passed) { + return Promise.reject('Authentication required before issuing a u2f registration request'); } - var user_data_store = req.app.get('user data store'); - - logger.debug('U2F register_handler: verify token validity and consume it'); - user_data_store.consume_u2f_registration_token(registration_token) - .then(function() { - res.render('u2f_register'); - }) - .catch(function(err) { - res.status(403); - res.send(); - }); -} - -function send_u2f_registration_email(email_sender, original_url, email, token) { - var url = util.format('%s?registration_token=%s', original_url, token); - var email_content = util.format('Register', url); - return email_sender.send(email, 'U2F Registration', email_content); -} - -function register_handler_post(req, res) { - var logger = req.app.get('logger'); - logger.info('U2F register_handler: Starting registration process'); - logger.debug('U2F register_request: headers=%s', JSON.stringify(req.headers)); - var userid = objectPath.get(req, 'session.auth_session.userid'); var email = objectPath.get(req, 'session.auth_session.email'); - var first_factor_passed = objectPath.get(req, 'session.auth_session.first_factor'); - - // the user needs to have validated the first factor - if(!(userid && first_factor_passed)) { - var error = 'You need to be authenticated to register'; - logger.error('U2F register_handler: %s', error); - res.status(403); - res.send(error); - return; + + if(!(userid && email)) { + return Promise.reject('User ID or email is missing'); } - if(!email) { - var error = util.format('No email has been found for user %s', userid); - logger.error('U2F register_handler: %s', error); - res.status(400); - res.send(error); - return; - } - - var five_minutes = 4 * 60 * 1000; - var user_data_store = req.app.get('user data store'); - var token = randomstring.generate({ length: 64 }); - - logger.debug('U2F register_request: issue u2f registration token %s for 5 minutes', token); - user_data_store.save_u2f_registration_token(userid, token, five_minutes) - .then(function() { - logger.debug('U2F register_request: Send u2f registration email to %s', email); - var email_sender = req.app.get('email sender'); - var original_url = u2f_common.extract_original_url(req); - return send_u2f_registration_email(email_sender, original_url, email, token); - }) - .then(function() { - res.status(204); - res.send(); - }) - .catch(function(err) { - logger.error('U2F register_handler: %s', err); - res.status(500); - res.send(); - }); + var identity = {}; + identity.email = email; + identity.userid = userid; + return Promise.resolve(identity); } + diff --git a/src/lib/routes/verify.js b/src/lib/routes/verify.js index 68b8e5aab..063328274 100644 --- a/src/lib/routes/verify.js +++ b/src/lib/routes/verify.js @@ -22,8 +22,6 @@ function verify_filter(req, res) { } function verify(req, res) { - console.log('Verify authentication'); - verify_filter(req, res) .then(function() { res.status(204); diff --git a/src/lib/server.js b/src/lib/server.js index e3d9f40ca..8c0e7ca67 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -11,12 +11,11 @@ var speakeasy = require('speakeasy'); var path = require('path'); var session = require('express-session'); var winston = require('winston'); -var DataStore = require('nedb'); -var nodemailer = require('nodemailer'); var UserDataStore = require('./user_data_store'); var EmailSender = require('./email_sender'); +var identity_check = require('./identity_check'); -function run(config, ldap_client, u2f, fn) { +function run(config, ldap_client, deps, fn) { var view_directory = path.resolve(__dirname, '../views'); var public_html_directory = path.resolve(__dirname, '../public_html'); var datastore_options = {}; @@ -47,32 +46,39 @@ function run(config, ldap_client, u2f, fn) { winston.level = config.debug_level || 'info'; app.set('logger', winston); + app.set('ldap', deps.ldap); 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('email sender', new EmailSender(nodemailer, email_options)); + app.set('u2f', deps.u2f); + app.set('user data store', new UserDataStore(deps.nedb, datastore_options)); + app.set('email sender', new EmailSender(deps.nodemailer, email_options)); app.set('config', config); + + var base_endpoint = '/authentication'; // web pages - app.get ('/login', routes.login); - app.get ('/logout', routes.logout); + app.get (base_endpoint + '/login', routes.login); + app.get (base_endpoint + '/logout', routes.logout); - app.get ('/u2f-register', routes.second_factor.u2f.register_handler_get); - app.post ('/u2f-register', routes.second_factor.u2f.register_handler_post); + identity_check(app, base_endpoint + '/u2f-register', routes.u2f_register.icheck_interface); + identity_check(app, base_endpoint + '/reset-password', routes.reset_password.icheck_interface); + app.get (base_endpoint + '/reset-password-form', function(req, res) { res.render('reset-password-form'); }); + + // Reset the password + app.post (base_endpoint + '/new-password', routes.reset_password.post); // verify authentication - app.get ('/verify', routes.verify); + app.get (base_endpoint + '/verify', routes.verify); // Authentication process - app.post ('/1stfactor', routes.first_factor); - app.post ('/2ndfactor/totp', routes.second_factor.totp); + app.post (base_endpoint + '/1stfactor', routes.first_factor); + app.post (base_endpoint + '/2ndfactor/totp', routes.second_factor.totp); - app.get ('/2ndfactor/u2f/register_request', routes.second_factor.u2f.register_request); - app.post ('/2ndfactor/u2f/register', routes.second_factor.u2f.register); + app.get (base_endpoint + '/2ndfactor/u2f/register_request', routes.second_factor.u2f.register_request); + app.post (base_endpoint + '/2ndfactor/u2f/register', routes.second_factor.u2f.register); - app.get ('/2ndfactor/u2f/sign_request', routes.second_factor.u2f.sign_request); - app.post ('/2ndfactor/u2f/sign', routes.second_factor.u2f.sign); + app.get (base_endpoint + '/2ndfactor/u2f/sign_request', routes.second_factor.u2f.sign_request); + app.post (base_endpoint + '/2ndfactor/u2f/sign', routes.second_factor.u2f.sign); return app.listen(config.port, function(err) { console.log('Listening on %d...', config.port); diff --git a/src/lib/user_data_store.js b/src/lib/user_data_store.js index 3a2c357f0..1816008b2 100644 --- a/src/lib/user_data_store.js +++ b/src/lib/user_data_store.js @@ -6,8 +6,8 @@ var path = require('path'); function UserDataStore(DataStore, options) { this._u2f_meta_collection = create_collection('u2f_meta', options, DataStore); - this._u2f_registration_tokens_collection = - create_collection('u2f_registration_tokens', options, DataStore); + this._identity_check_tokens_collection = + create_collection('identity_check_tokens', options, DataStore); } function create_collection(name, options, DataStore) { @@ -42,35 +42,41 @@ UserDataStore.prototype.get_u2f_meta = function(userid, app_id) { return this._u2f_meta_collection.findOneAsync(filter); } -UserDataStore.prototype.save_u2f_registration_token = function(userid, token, max_age) { +UserDataStore.prototype.issue_identity_check_token = function(userid, token, data, max_age) { var newDocument = {}; newDocument.userid = userid; newDocument.token = token; + newDocument.content = { userid: userid, data: data }; newDocument.max_date = new Date(new Date().getTime() + max_age); - return this._u2f_registration_tokens_collection.insertAsync(newDocument); + return this._identity_check_tokens_collection.insertAsync(newDocument); } -UserDataStore.prototype.consume_u2f_registration_token = function(token) { +UserDataStore.prototype.consume_identity_check_token = function(token) { var query = {}; query.token = token; var that = this; + var doc_content; - return this._u2f_registration_tokens_collection.findOneAsync(query) + return this._identity_check_tokens_collection.findOneAsync(query) .then(function(doc) { - if(!doc) { - return Promise.reject('Registration token does not exist'); - } + if(!doc) { + return Promise.reject('Registration token does not exist'); + } - var max_date = doc.max_date; - var current_date = new Date(); - if(current_date > max_date) { - return Promise.reject('Registration token is not valid anymore'); - } + var max_date = doc.max_date; + var current_date = new Date(); + if(current_date > max_date) { + return Promise.reject('Registration token is not valid anymore'); + } - return Promise.resolve(); + doc_content = doc.content; + return Promise.resolve(); }) .then(function() { - return that._u2f_registration_tokens_collection.removeAsync(query); - }); + return that._identity_check_tokens_collection.removeAsync(query); + }) + .then(function() { + return Promise.resolve(doc_content); + }) } diff --git a/src/public_html/login.css b/src/public_html/css/login.css similarity index 98% rename from src/public_html/login.css rename to src/public_html/css/login.css index d5427dc02..9c25018e3 100644 --- a/src/public_html/login.css +++ b/src/public_html/css/login.css @@ -43,6 +43,8 @@ body { .login h2 { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; font-size: 1em; } +.login p { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; } + input { width: 100%; margin-bottom: 10px; @@ -98,6 +100,6 @@ input:focus { box-shadow: inset 0 -5px 45px rgba(100,100,100,0.4), 0 1px 1px rgb float: right; } -#second-factor #u2f button { +button { margin-top: 5px; } diff --git a/src/public_html/jquery.min.js b/src/public_html/js/jquery.min.js similarity index 100% rename from src/public_html/jquery.min.js rename to src/public_html/js/jquery.min.js diff --git a/src/public_html/login.js b/src/public_html/js/login.js similarity index 89% rename from src/public_html/login.js rename to src/public_html/js/login.js index 7c0e22cd2..49333cb9c 100644 --- a/src/public_html/login.js +++ b/src/public_html/js/login.js @@ -30,6 +30,11 @@ function onLoginButtonClicked() { }); } +function onResetPasswordButtonClicked() { + var r = '/authentication/reset-password-form'; + window.location.replace(r); +} + function onTotpSignButtonClicked() { var token = $('#totp-token').val(); validateSecondFactorTotp(token, function(err) { @@ -64,7 +69,7 @@ function onU2fRegistrationButtonClicked() { function askForU2fRegistration(fn) { $.ajax({ type: 'POST', - url: '/auth/u2f-register' + url: '/authentication/u2f-register' }) .done(function(data) { fn(undefined, data); @@ -91,7 +96,7 @@ function finishU2fAuthentication(url, responseData, fn) { } function startU2fAuthentication(fn, timeout) { - $.get('/auth/2ndfactor/u2f/sign_request', {}, null, 'json') + $.get('/authentication/2ndfactor/u2f/sign_request', {}, null, 'json') .done(function(signResponse) { var registeredKeys = signResponse.registeredKeys; $.notify('Please touch the token', 'info'); @@ -104,7 +109,7 @@ function startU2fAuthentication(fn, timeout) { if (response.errorCode) { fn(response); } else { - finishU2fAuthentication('/auth/2ndfactor/u2f/sign', response, fn); + finishU2fAuthentication('/authentication/2ndfactor/u2f/sign', response, fn); } }, timeout @@ -116,7 +121,7 @@ function startU2fAuthentication(fn, timeout) { } function validateSecondFactorTotp(token, fn) { - $.post('/auth/2ndfactor/totp', { + $.post('/authentication/2ndfactor/totp', { token: token, }) .done(function() { @@ -128,7 +133,7 @@ function validateSecondFactorTotp(token, fn) { } function validateFirstFactor(username, password, fn) { - $.post('/auth/1stfactor', { + $.post('/authentication/1stfactor', { username: username, password: password, }) @@ -200,7 +205,6 @@ function hideSecondFactorLayout() { function setupFirstFactorLoginButton() { $('#first-factor #login-button').on('click', onLoginButtonClicked); setupEnterKeypressListener('#login-form', onLoginButtonClicked); - $('#first-factor #information').hide(); } function cleanupFirstFactorLoginButton() { @@ -221,10 +225,15 @@ function setupU2fRegistrationButton() { $('#second-factor #u2f-register-button').on('click', onU2fRegistrationButtonClicked); } +function setupResetPasswordButton() { + $('#first-factor #reset-password-button').on('click', onResetPasswordButtonClicked); +} + function enterFirstFactor() { showFirstFactorLayout(); hideSecondFactorLayout(); setupFirstFactorLoginButton(); + setupResetPasswordButton(); } function enterSecondFactor() { diff --git a/src/public_html/notify.min.js b/src/public_html/js/notify.min.js similarity index 100% rename from src/public_html/notify.min.js rename to src/public_html/js/notify.min.js diff --git a/src/public_html/js/reset-password-form.js b/src/public_html/js/reset-password-form.js new file mode 100644 index 000000000..f73a0eb2a --- /dev/null +++ b/src/public_html/js/reset-password-form.js @@ -0,0 +1,47 @@ +(function() { + +function setupEnterKeypressListener(filter, fn) { + $(filter).on('keydown', 'input', function (e) { + var key = e.which; + switch (key) { + case 13: // enter key code + fn(); + break; + default: + break; + } + }); +} + +function onResetPasswordButtonClicked() { + var username = $('#username').val(); + + if(!username) { + $.notify('You must provide your username to reset your password.', 'warn'); + return; + } + + $.post('/authentication/reset-password', { + userid: username, + }) + .done(function() { + $.notify('An email has been sent. Click on the link to change your password', 'success'); + setTimeout(function() { + window.location.replace('/authentication/login'); + }, 1000); + }) + .fail(function() { + $.notify('Are you sure this is your username?', 'warn'); + }); +} + +function setupResetPasswordButton() { + $('#reset-password-button').on('click', onResetPasswordButtonClicked); +} + +$(document).ready(function() { + setupResetPasswordButton(); + setupEnterKeypressListener('#reset-password-form', onResetPasswordButtonClicked); +}); + +})(); diff --git a/src/public_html/js/reset-password.js b/src/public_html/js/reset-password.js new file mode 100644 index 000000000..85520c643 --- /dev/null +++ b/src/public_html/js/reset-password.js @@ -0,0 +1,51 @@ +(function() { + +function setupEnterKeypressListener(filter, fn) { + $(filter).on('keydown', 'input', function (e) { + var key = e.which; + switch (key) { + case 13: // enter key code + fn(); + break; + default: + break; + } + }); +} + +function onResetPasswordButtonClicked() { + var password1 = $('#password1').val(); + var password2 = $('#password2').val(); + + if(!password1 || !password2) { + $.notify('You must enter your new password twice.', 'warn'); + return; + } + + if(password1 != password2) { + $.notify('The passwords are different', 'warn'); + return; + } + + $.post('/authentication/new-password', { + password: password1, + }) + .done(function() { + $.notify('Your password has been changed. Please login again', 'success'); + window.location.replace('/authentication/login'); + }) + .fail(function() { + $.notify('An error occurred during password change.', 'warn'); + }); +} + +function setupResetPasswordButton() { + $('#reset-password-button').on('click', onResetPasswordButtonClicked); +} + +$(document).ready(function() { + setupResetPasswordButton(); + setupEnterKeypressListener('#reset-password-form', onResetPasswordButtonClicked); +}); + +})(); diff --git a/src/public_html/u2f-api.js b/src/public_html/js/u2f-api.js similarity index 100% rename from src/public_html/u2f-api.js rename to src/public_html/js/u2f-api.js diff --git a/src/public_html/u2f-register.js b/src/public_html/js/u2f-register.js similarity index 88% rename from src/public_html/u2f-register.js rename to src/public_html/js/u2f-register.js index 56b6721a9..619e6ad68 100644 --- a/src/public_html/u2f-register.js +++ b/src/public_html/js/u2f-register.js @@ -4,7 +4,6 @@ params={}; location.search.replace(/[?&]+([^=&]+)=([^&]*)/gi,function(s,k,v){params[k]=v}); function finishRegister(url, responseData, fn) { - console.log(responseData); $.ajax({ type: 'POST', url: url, @@ -21,7 +20,7 @@ function finishRegister(url, responseData, fn) { } function startRegister(fn, timeout) { - $.get('/auth/2ndfactor/u2f/register_request', {}, null, 'json') + $.get('/authentication/2ndfactor/u2f/register_request', {}, null, 'json') .done(function(startRegisterResponse) { u2f.register( startRegisterResponse.appId, @@ -31,7 +30,7 @@ function startRegister(fn, timeout) { if (response.errorCode) { fn(response.errorCode); } else { - finishRegister('/auth/2ndfactor/u2f/register', response, fn); + finishRegister('/authentication/2ndfactor/u2f/register', response, fn); } }, timeout diff --git a/src/views/head.ejs b/src/views/head.ejs new file mode 100644 index 000000000..618957e4e --- /dev/null +++ b/src/views/head.ejs @@ -0,0 +1 @@ + diff --git a/src/views/login.ejs b/src/views/login.ejs index 5278bf210..f83606825 100644 --- a/src/views/login.ejs +++ b/src/views/login.ejs @@ -1,7 +1,7 @@ - Login Portal - + Login + <% include head %>
@@ -10,6 +10,7 @@ +
@@ -27,8 +28,7 @@ - - - - + <% include scripts %> + + diff --git a/src/views/reset-password-form.ejs b/src/views/reset-password-form.ejs new file mode 100644 index 000000000..7a4f44d01 --- /dev/null +++ b/src/views/reset-password-form.ejs @@ -0,0 +1,18 @@ + + + Reset Password + <% include head %> + + +
+

Reset your password

+

What's your username? You will receive an email to change your password

+
+ + +
+
+ + <% include scripts %> + + diff --git a/src/views/reset-password.ejs b/src/views/reset-password.ejs new file mode 100644 index 000000000..603417549 --- /dev/null +++ b/src/views/reset-password.ejs @@ -0,0 +1,19 @@ + + + Reset Password + <% include head %> + + +
+

Reset your password

+

Please type your new password.

+
+ + + +
+
+ + <% include scripts %> + + diff --git a/src/views/scripts.ejs b/src/views/scripts.ejs new file mode 100644 index 000000000..49ad79ac8 --- /dev/null +++ b/src/views/scripts.ejs @@ -0,0 +1,2 @@ + + diff --git a/src/views/u2f_register.ejs b/src/views/u2f-register.ejs similarity index 50% rename from src/views/u2f_register.ejs rename to src/views/u2f-register.ejs index 0bf8b353d..d7b743eae 100644 --- a/src/views/u2f_register.ejs +++ b/src/views/u2f-register.ejs @@ -1,7 +1,7 @@ FIDO U2F Registration - + <% include head %>
@@ -10,8 +10,7 @@
- - - - + <% include scripts %> + + diff --git a/test/unitary/requests.js b/test/unitary/requests.js new file mode 100644 index 000000000..deb7a06cb --- /dev/null +++ b/test/unitary/requests.js @@ -0,0 +1,130 @@ + +var Promise = require('bluebird'); +var request = Promise.promisifyAll(require('request')); +var assert = require('assert'); + +module.exports = function(port) { + var PORT = port; + var BASE_URL = 'http://localhost:' + PORT; + + function execute_reset_password(jar, transporter, user, new_password) { + return request.postAsync({ + url: BASE_URL + '/authentication/reset-password', + jar: jar, + form: { userid: user } + }) + .then(function(res) { + assert.equal(res.statusCode, 204); + var html_content = transporter.sendMail.getCall(0).args[0].html; + var regexp = /identity_token=([a-zA-Z0-9]+)/; + var token = regexp.exec(html_content)[1]; + // console.log(html_content, token); + return request.getAsync({ + url: BASE_URL + '/authentication/reset-password?identity_token=' + token, + jar: jar + }) + }) + .then(function(res) { + assert.equal(res.statusCode, 200); + return request.postAsync({ + url: BASE_URL + '/authentication/new-password', + jar: jar, + form: { + password: new_password + } + }); + }); + } + + function execute_totp(jar, token) { + return request.postAsync({ + url: BASE_URL + '/authentication/2ndfactor/totp', + jar: jar, + form: { + token: token + } + }); + } + + function execute_u2f_authentication(jar) { + return request.getAsync({ + url: BASE_URL + '/authentication/2ndfactor/u2f/sign_request', + jar: jar + }) + .then(function(res) { + assert.equal(res.statusCode, 200); + return request.postAsync({ + url: BASE_URL + '/authentication/2ndfactor/u2f/sign', + jar: jar, + form: { + } + }); + }); + } + + function execute_verification(jar) { + return request.getAsync({ url: BASE_URL + '/authentication/verify', jar: jar }) + } + + function execute_login(jar) { + return request.getAsync({ url: BASE_URL + '/authentication/login', jar: jar }) + } + + function execute_u2f_registration(jar, transporter) { + return request.postAsync({ + url: BASE_URL + '/authentication/u2f-register', + jar: jar + }) + .then(function(res) { + assert.equal(res.statusCode, 204); + var html_content = transporter.sendMail.getCall(0).args[0].html; + var regexp = /identity_token=([a-zA-Z0-9]+)/; + var token = regexp.exec(html_content)[1]; + // console.log(html_content, token); + return request.getAsync({ + url: BASE_URL + '/authentication/u2f-register?identity_token=' + token, + jar: jar + }) + }) + .then(function(res) { + assert.equal(res.statusCode, 200); + return request.getAsync({ + url: BASE_URL + '/authentication/2ndfactor/u2f/register_request', + jar: jar, + }); + }) + .then(function(res) { + assert.equal(res.statusCode, 200); + return request.postAsync({ + url: BASE_URL + '/authentication/2ndfactor/u2f/register', + jar: jar, + form: { + s: 'test' + } + }); + }); + } + + function execute_first_factor(jar) { + return request.postAsync({ + url: BASE_URL + '/authentication/1stfactor', + jar: jar, + form: { + username: 'test_ok', + password: 'password' + } + }); + } + + return { + login: execute_login, + verify: execute_verification, + reset_password: execute_reset_password, + u2f_authentication: execute_u2f_authentication, + u2f_registration: execute_u2f_registration, + first_factor: execute_first_factor, + totp: execute_totp, + } + +} + diff --git a/test/unitary/routes/test_reset_password.js b/test/unitary/routes/test_reset_password.js new file mode 100644 index 000000000..a92d56c53 --- /dev/null +++ b/test/unitary/routes/test_reset_password.js @@ -0,0 +1,140 @@ +var sinon = require('sinon'); +var winston = require('winston'); +var reset_password = require('../../../src/lib/routes/reset_password'); +var assert = require('assert'); + +describe('test reset password', function() { + var req, res; + var user_data_store; + var ldap_client; + var ldap; + + beforeEach(function() { + req = {} + req.body = {}; + req.body.userid = 'user'; + req.app = {}; + req.app.get = sinon.stub(); + req.app.get.withArgs('logger').returns(winston); + req.session = {}; + req.session.auth_session = {}; + req.session.auth_session.userid = 'user'; + req.session.auth_session.email = 'user@example.com'; + req.session.auth_session.first_factor = true; + req.session.auth_session.second_factor = false; + 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({})); + user_data_store.issue_identity_check_token = sinon.stub().returns(Promise.resolve({})); + user_data_store.consume_identity_check_token = sinon.stub().returns(Promise.resolve({})); + req.app.get.withArgs('user data store').returns(user_data_store); + + ldap = {}; + ldap.Change = sinon.spy(); + req.app.get.withArgs('ldap').returns(ldap); + + ldap_client = {}; + ldap_client.bind = sinon.stub(); + ldap_client.search = sinon.stub(); + ldap_client.modify = sinon.stub(); + req.app.get.withArgs('ldap client').returns(ldap_client); + + config = {}; + config.ldap_users_dn = 'dc=example,dc=com'; + req.app.get.withArgs('config').returns(config); + + res = {}; + res.send = sinon.spy(); + res.json = sinon.spy(); + res.status = sinon.spy(); + }); + + describe('test reset password identity pre check', test_reset_password_check); + describe('test reset password post', test_reset_password_post); + + function test_reset_password_check() { + it('should fail when no userid is provided', function(done) { + req.body.userid = undefined; + reset_password.icheck_interface.pre_check_callback(req) + .catch(function(err) { + done(); + }); + }); + + it('should fail if ldap fail', function(done) { + ldap_client.search.yields('Internal error'); + reset_password.icheck_interface.pre_check_callback(req) + .catch(function(err) { + done(); + }); + }); + + it('should returns identity when ldap replies', function(done) { + var doc = {}; + doc.object = {}; + doc.object.email = 'test@example.com'; + doc.object.userid = 'user'; + + var res = {}; + res.on = sinon.stub(); + res.on.withArgs('searchEntry').yields(doc); + res.on.withArgs('end').yields(); + + ldap_client.search.yields(undefined, res); + reset_password.icheck_interface.pre_check_callback(req) + .then(function() { + done(); + }); + }); + } + + function test_reset_password_post() { + it('should update the password', function(done) { + req.session.auth_session.identity_check = {}; + req.session.auth_session.identity_check.userid = 'user'; + req.session.auth_session.identity_check.challenge = 'reset-password'; + req.body = {}; + req.body.password = 'new-password'; + + ldap_client.modify.yields(undefined); + ldap_client.bind.yields(undefined); + res.send = sinon.spy(function() { + assert.equal(ldap_client.modify.getCall(0).args[0], 'cn=user,dc=example,dc=com'); + assert.equal(res.status.getCall(0).args[0], 204); + done(); + }); + reset_password.post(req, res); + }); + + it('should fail if identity_challenge does not exist', function(done) { + req.session.auth_session.identity_check = {}; + req.session.auth_session.identity_check.challenge = undefined; + res.send = sinon.spy(function() { + assert.equal(res.status.getCall(0).args[0], 403); + done(); + }); + reset_password.post(req, res); + }); + + it('should fail when ldap fails', function(done) { + req.session.auth_session.identity_check = {}; + req.session.auth_session.identity_check.challenge = 'reset-password'; + req.body = {}; + req.body.password = 'new-password'; + + ldap_client.bind.yields(undefined); + ldap_client.modify.yields('Internal error with LDAP'); + res.send = sinon.spy(function() { + assert.equal(res.status.getCall(0).args[0], 500); + done(); + }); + reset_password.post(req, res); + }); + } +}); diff --git a/test/unitary/routes/test_u2f.js b/test/unitary/routes/test_u2f.js index 2d939d4ed..401152d2b 100644 --- a/test/unitary/routes/test_u2f.js +++ b/test/unitary/routes/test_u2f.js @@ -19,6 +19,9 @@ describe('test u2f routes', function() { req.session.auth_session.userid = 'user'; req.session.auth_session.first_factor = true; req.session.auth_session.second_factor = false; + req.session.auth_session.identity_check = {}; + req.session.auth_session.identity_check.challenge = 'u2f-register'; + req.session.auth_session.register_request = {}; req.headers = {}; req.headers.host = 'localhost'; @@ -73,6 +76,15 @@ describe('test u2f routes', function() { req.app.get.withArgs('u2f').returns(u2f_mock); u2f.register_request(req, res); }); + + it('should return forbidden if identity has not been verified', function(done) { + res.send = sinon.spy(function(data) { + assert.equal(403, res.status.getCall(0).args[0]); + done(); + }); + req.session.auth_session.identity_check = undefined; + u2f.register_request(req, res); + }); } function test_registration() { @@ -97,7 +109,7 @@ describe('test u2f routes', function() { it('should return unauthorized on finishRegistration error', function(done) { res.send = sinon.spy(function(data) { - assert.equal(401, res.status.getCall(0).args[0]); + assert.equal(500, res.status.getCall(0).args[0]); done(); }); var user_key_container = {}; @@ -110,9 +122,9 @@ describe('test u2f routes', function() { u2f.register(req, res); }); - it('should return unauthorized error when no auth request has been initiated', function(done) { + it('should return forbidden error when no auth request has been initiated', function(done) { res.send = sinon.spy(function(data) { - assert.equal(401, res.status.getCall(0).args[0]); + assert.equal(403, res.status.getCall(0).args[0]); done(); }); var user_key_container = {}; @@ -120,9 +132,19 @@ describe('test u2f routes', function() { u2f_mock.finishRegistration = sinon.stub(); u2f_mock.finishRegistration.returns(Promise.resolve()); + req.session.auth_session.register_request = undefined; req.app.get.withArgs('u2f').returns(u2f_mock); u2f.register(req, res); }); + + it('should return forbidden error when identity has not been verified', function(done) { + res.send = sinon.spy(function(data) { + assert.equal(403, res.status.getCall(0).args[0]); + done(); + }); + req.session.auth_session.identity_check = undefined; + u2f.register(req, res); + }); } function test_signing_request() { @@ -148,7 +170,7 @@ describe('test u2f routes', function() { it('should return unauthorized error on registration request error', function(done) { res.send = sinon.spy(function(data) { - assert.equal(401, res.status.getCall(0).args[0]); + assert.equal(500, res.status.getCall(0).args[0]); done(); }); var user_key_container = {}; @@ -209,7 +231,7 @@ describe('test u2f routes', function() { it('should return unauthorized error on registration request internal error', function(done) { res.send = sinon.spy(function(data) { - assert.equal(401, res.status.getCall(0).args[0]); + assert.equal(500, res.status.getCall(0).args[0]); done(); }); var user_key_container = {}; diff --git a/test/unitary/routes/test_u2f_register.js b/test/unitary/routes/test_u2f_register.js index 86a904dcd..c3860ea5a 100644 --- a/test/unitary/routes/test_u2f_register.js +++ b/test/unitary/routes/test_u2f_register.js @@ -1,10 +1,9 @@ - var sinon = require('sinon'); var winston = require('winston'); -var u2f_register = require('../../../src/lib/routes/u2f_register'); +var u2f_register = require('../../../src/lib/routes/u2f_register_handler'); var assert = require('assert'); -describe('test register handle', function() { +describe('test register handler', function() { var req, res; var user_data_store; @@ -28,88 +27,52 @@ describe('test register handle', function() { 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({})); - user_data_store.save_u2f_registration_token = sinon.stub().returns(Promise.resolve({})); - user_data_store.consume_u2f_registration_token = sinon.stub().returns(Promise.resolve({})); + user_data_store.issue_identity_check_token = sinon.stub().returns(Promise.resolve({})); + user_data_store.consume_identity_check_token = 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(); - }) + }); + describe('test u2f registration check', test_registration_check); - describe('test registration handler (POST)', test_registration_handler_post); - describe('test registration handler (GET)', test_registration_handler_get); - - function test_registration_handler_post() { - it('should issue a registration token', function(done) { - res.send = sinon.spy(function() { - assert.equal(204, res.status.getCall(0).args[0]); - assert.equal('user', user_data_store.save_u2f_registration_token.getCall(0).args[0]); - assert.equal(4 * 60 * 1000, user_data_store.save_u2f_registration_token.getCall(0).args[2]); + function test_registration_check() { + it('should fail if first_factor has not been passed', function(done) { + req.session.auth_session.first_factor = false; + u2f_register.icheck_interface.pre_check_callback(req) + .catch(function(err) { done(); }); - var email_sender = {}; - email_sender.send = sinon.stub().returns(Promise.resolve()); - req.app.get.withArgs('email sender').returns(email_sender); - u2f_register.register_handler_post(req, res); }); - it('should fail during issuance of a registration token', function(done) { - res.send = sinon.spy(function() { - assert.equal(500, res.status.getCall(0).args[0]); + it('should fail if userid is missing', function(done) { + req.session.auth_session.first_factor = false; + req.session.auth_session.userid = undefined; + + u2f_register.icheck_interface.pre_check_callback(req) + .catch(function(err) { done(); }); - user_data_store.save_u2f_registration_token = sinon.stub().returns(Promise.reject('Error')); - u2f_register.register_handler_post(req, res); }); - it('should send bad request if no email has been found for the given user', function(done) { - res.send = sinon.spy(function() { - assert.equal(400, res.status.getCall(0).args[0]); - done(); - }); + it('should fail if email is missing', function(done) { + req.session.auth_session.first_factor = false; req.session.auth_session.email = undefined; - var email_sender = {}; - email_sender.send = sinon.stub().returns(Promise.resolve()); - req.app.get.withArgs('email sender').returns(email_sender); - u2f_register.register_handler_post(req, res); - }); - } - - function test_registration_handler_get() { - it('should send forbidden if no registration_token has been provided', function(done) { - res.send = sinon.spy(function() { - assert.equal(403, res.status.getCall(0).args[0]); + u2f_register.icheck_interface.pre_check_callback(req) + .catch(function(err) { done(); }); - u2f_register.register_handler_get(req, res); }); - - it('should render the u2f-register view when registration token is still valid', function(done) { - res.render = sinon.spy(function(data) { - assert.equal('u2f_register', data); + it('should succeed if first factor passed, userid and email are provided', function(done) { + u2f_register.icheck_interface.pre_check_callback(req) + .then(function(err) { done(); }); - req.query = {}; - req.query.registration_token = 'token'; - u2f_register.register_handler_get(req, res); - }); - - it('should send forbidden status when registration token is not valid', function(done) { - res.send = sinon.spy(function(data) { - assert.equal(403, res.status.getCall(0).args[0]); - done(); - }); - - req.params = {}; - req.params.registration_token = 'token'; - user_data_store.consume_u2f_registration_token = sinon.stub().returns(Promise.reject('Not valid anymore')); - - u2f_register.register_handler_get(req, res); }); } }); diff --git a/test/unitary/test_data_persistence.js b/test/unitary/test_data_persistence.js index 80dc876b1..072f9c361 100644 --- a/test/unitary/test_data_persistence.js +++ b/test/unitary/test_data_persistence.js @@ -1,18 +1,20 @@ var server = require('../../src/lib/server'); -var request = require('request'); +var Promise = require('bluebird'); +var request = Promise.promisifyAll(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 nedb = require('nedb'); var PORT = 8050; var BASE_URL = 'http://localhost:' + PORT; +var requests = require('./requests')(PORT); + + describe('test data persistence', function() { var u2f; var tmpDir; @@ -73,38 +75,52 @@ describe('test data persistence', function() { u2f.finishRegistration.returns(Promise.resolve(sign_status)); u2f.startAuthentication.returns(Promise.resolve(registration_request)); u2f.finishAuthentication.returns(Promise.resolve(registration_status)); - + + var nodemailer = {}; + var transporter = { + sendMail: sinon.stub().yields() + }; + nodemailer.createTransport = sinon.spy(function() { + return transporter; + }); + + var deps = {}; + deps.u2f = u2f; + deps.nedb = nedb; + deps.nodemailer = nodemailer; + var j1 = request.jar(); var j2 = request.jar(); - return start_server(config, ldap_client, u2f) + + return start_server(config, ldap_client, deps) .then(function(s) { server = s; - return execute_login(j1); + return requests.login(j1); }) .then(function(res) { - return execute_first_factor(j1); + return requests.first_factor(j1); }) .then(function() { - return execute_u2f_registration(j1); + return requests.u2f_registration(j1, transporter); }) .then(function() { - return execute_u2f_authentication(j1); + return requests.u2f_authentication(j1); }) .then(function() { return stop_server(server); }) .then(function() { - return start_server(config, ldap_client, u2f) + return start_server(config, ldap_client, deps) }) .then(function(s) { server = s; - return execute_login(j2); + return requests.login(j2); }) .then(function() { - return execute_first_factor(j2); + return requests.first_factor(j2); }) .then(function() { - return execute_u2f_authentication(j2); + return requests.u2f_authentication(j2); }) .then(function(res) { assert.equal(204, res.statusCode); @@ -117,9 +133,9 @@ describe('test data persistence', function() { }); }); - function start_server(config, ldap_client, u2f) { + function start_server(config, ldap_client, deps) { return new Promise(function(resolve, reject) { - var s = server.run(config, ldap_client, u2f); + var s = server.run(config, ldap_client, deps); resolve(s); }); } @@ -130,55 +146,4 @@ describe('test data persistence', function() { resolve(); }); } - - function execute_first_factor(jar) { - return request.postAsync({ - url: BASE_URL + '/1stfactor', - jar: jar, - form: { - username: 'test_ok', - password: 'password' - } - }); - } - - function execute_u2f_registration(jar) { - return request.getAsync({ - url: BASE_URL + '/2ndfactor/u2f/register_request', - jar: jar - }) - .then(function(res) { - return request.postAsync({ - url: BASE_URL + '/2ndfactor/u2f/register', - jar: jar, - form: { - s: 'test' - } - }); - }); - } - - function execute_u2f_authentication(jar) { - return request.getAsync({ - url: BASE_URL + '/2ndfactor/u2f/sign_request', - jar: jar - }) - .then(function() { - return request.postAsync({ - url: BASE_URL + '/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_identity_check.js b/test/unitary/test_identity_check.js new file mode 100644 index 000000000..ae2b571b5 --- /dev/null +++ b/test/unitary/test_identity_check.js @@ -0,0 +1,208 @@ + +var sinon = require('sinon'); +var identity_check = require('../../src/lib/identity_check'); +var exceptions = require('../../src/lib/exceptions'); +var assert = require('assert'); +var winston = require('winston'); +var Promise = require('bluebird'); + +describe('test identity check process', function() { + var req, res, app, icheck_interface; + var user_data_store; + var email_sender; + + beforeEach(function() { + req = {}; + res = {}; + + app = {}; + icheck_interface = {}; + icheck_interface.pre_check_callback = sinon.stub(); + + user_data_store = {}; + user_data_store.issue_identity_check_token = sinon.stub(); + user_data_store.issue_identity_check_token.returns(Promise.resolve()); + user_data_store.consume_identity_check_token = sinon.stub(); + user_data_store.consume_identity_check_token.returns(Promise.resolve({ userid: 'user' })); + + email_sender = {}; + email_sender.send = sinon.stub(); + email_sender.send = sinon.stub().returns(Promise.resolve()); + + req.headers = {}; + req.session = {}; + req.session.auth_session = {}; + + req.query = {}; + req.app = {}; + 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('email sender').returns(email_sender); + + res.status = sinon.spy(); + res.send = sinon.spy(); + res.redirect = sinon.spy(); + res.render = sinon.spy(); + + app.get = sinon.spy(); + app.post = sinon.spy(); + }); + + it('should register a POST and GET endpoint', function() { + var app = {}; + app.get = sinon.spy(); + app.post = sinon.spy(); + var endpoint = '/test'; + var icheck_interface = {}; + + identity_check(app, endpoint, icheck_interface); + + assert(app.get.calledOnce); + assert(app.get.calledWith(endpoint)); + + assert(app.post.calledOnce); + assert(app.post.calledWith(endpoint)); + }); + + describe('test POST', test_post_handler); + describe('test GET', test_get_handler); + + + function test_post_handler() { + it('should send 403 if pre check rejects', function(done) { + var endpoint = '/protected'; + + icheck_interface.pre_check_callback.returns(Promise.reject('No access')); + identity_check(app, endpoint, icheck_interface); + + res.send = sinon.spy(function() { + assert.equal(res.status.getCall(0).args[0], 403); + done(); + }); + + var handler = app.post.getCall(0).args[1]; + handler(req, res); + }); + + it('should send 400 if email is missing in provided identity', function(done) { + var endpoint = '/protected'; + var identity = { userid: 'abc' }; + + icheck_interface.pre_check_callback.returns(Promise.resolve(identity)); + identity_check(app, endpoint, icheck_interface); + + res.send = sinon.spy(function() { + assert.equal(res.status.getCall(0).args[0], 400); + done(); + }); + + var handler = app.post.getCall(0).args[1]; + handler(req, res); + }); + + it('should send 400 if userid is missing in provided identity', function(done) { + var endpoint = '/protected'; + var identity = { email: 'abc@example.com' }; + + icheck_interface.pre_check_callback.returns(Promise.resolve(identity)); + identity_check(app, endpoint, icheck_interface); + + res.send = sinon.spy(function() { + assert.equal(res.status.getCall(0).args[0], 400); + done(); + }); + var handler = app.post.getCall(0).args[1]; + handler(req, res); + }); + + it('should issue a token, send an email and return 204', function(done) { + var endpoint = '/protected'; + var identity = { userid: 'user', email: 'abc@example.com' }; + req.headers.host = 'localhost'; + req.headers['x-original-uri'] = '/auth/test'; + + icheck_interface.pre_check_callback.returns(Promise.resolve(identity)); + identity_check(app, endpoint, icheck_interface); + + res.send = sinon.spy(function() { + assert.equal(res.status.getCall(0).args[0], 204); + assert(email_sender.send.calledOnce); + assert(user_data_store.issue_identity_check_token.calledOnce); + assert.equal(user_data_store.issue_identity_check_token.getCall(0).args[0], 'user'); + assert.equal(user_data_store.issue_identity_check_token.getCall(0).args[3], 240000); + assert(email_sender.send.getCall(0).args[2].startsWith('