From d3db94105e6b1ea44b8f7e6fad72cead71807526 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sun, 22 Jan 2017 17:54:45 +0100 Subject: [PATCH] Registration process sends an email to allow user to register its U2F device --- .gitignore | 4 + Dockerfile | 3 +- config.template.yml | 19 +++ docker-compose.dev.yml | 7 ++ docker-compose.yml | 27 ++-- example/ldap/base.ldif | 31 +++++ {nginx_conf => example/nginx_conf}/index.html | 0 {nginx_conf => example/nginx_conf}/nginx.conf | 0 .../nginx_conf}/secret.html | 0 .../nginx_conf}/ssl/server.crt | 0 .../nginx_conf}/ssl/server.csr | 0 .../nginx_conf}/ssl/server.key | 0 package.json | 6 +- src/index.js | 22 +++- src/lib/email_sender.js | 25 ++++ src/lib/exceptions.js | 17 +++ src/lib/ldap.js | 37 +++++- src/lib/routes.js | 9 +- src/lib/routes/first_factor.js | 30 ++++- src/lib/routes/second_factor.js | 7 +- src/lib/routes/u2f.js | 95 +++------------ src/lib/routes/u2f_common.js | 38 ++++++ src/lib/routes/u2f_register.js | 71 +++++++++++ src/lib/routes/u2f_register_handler.js | 93 ++++++++++++++ src/lib/server.js | 13 ++ src/lib/user_data_store.js | 41 ++++++- src/public_html/img/pendrive.png | Bin 0 -> 6721 bytes src/public_html/login.js | 93 +++++--------- src/public_html/u2f-register.js | 68 +++++++++++ src/views/u2f_register.ejs | 17 +++ test/integration/test_server.js | 6 +- test/unitary/routes/test_first_factor.js | 32 ++++- test/unitary/routes/test_u2f.js | 3 +- test/unitary/routes/test_u2f_register.js | 115 ++++++++++++++++++ test/unitary/test_data_persistence.js | 21 +++- test/unitary/test_email_sender.js | 31 +++++ test/unitary/test_ldap.js | 87 +++++++++---- test/unitary/test_server.js | 67 +++++++++- test/unitary/test_user_data_store.js | 96 ++++++++++++++- 39 files changed, 1012 insertions(+), 219 deletions(-) create mode 100644 config.template.yml create mode 100644 example/ldap/base.ldif rename {nginx_conf => example/nginx_conf}/index.html (100%) rename {nginx_conf => example/nginx_conf}/nginx.conf (100%) rename {nginx_conf => example/nginx_conf}/secret.html (100%) rename {nginx_conf => example/nginx_conf}/ssl/server.crt (100%) rename {nginx_conf => example/nginx_conf}/ssl/server.csr (100%) rename {nginx_conf => example/nginx_conf}/ssl/server.key (100%) create mode 100644 src/lib/email_sender.js create mode 100644 src/lib/exceptions.js create mode 100644 src/lib/routes/u2f_common.js create mode 100644 src/lib/routes/u2f_register.js create mode 100644 src/lib/routes/u2f_register_handler.js create mode 100644 src/public_html/img/pendrive.png create mode 100644 src/public_html/u2f-register.js create mode 100644 src/views/u2f_register.ejs create mode 100644 test/unitary/routes/test_u2f_register.js create mode 100644 test/unitary/test_email_sender.js diff --git a/.gitignore b/.gitignore index 924d1e3ac..7a4b1e5aa 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,7 @@ coverage/ *.swp *.sh + +config.yml + +npm-debug.log diff --git a/Dockerfile b/Dockerfile index 72af1bb0c..1bf24d1da 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,6 +10,7 @@ COPY src /usr/src ENV PORT=80 EXPOSE 80 +VOLUME /etc/auth-server VOLUME /var/lib/auth-server -CMD ["node", "index.js"] +CMD ["node", "index.js", "/etc/auth-server/config.yml"] diff --git a/config.template.yml b/config.template.yml new file mode 100644 index 000000000..bef6d1089 --- /dev/null +++ b/config.template.yml @@ -0,0 +1,19 @@ + +ldap: + url: ldap://ldap + base_dn: ou=users,dc=example,dc=com + +# Will be per user soon +totp_secret: GRWGIJS6IRHVEODVNRCXCOBMJ5AGC6ZE + +session: + secret: unsecure_secret + expiration: 3600000 + +store_directory: /var/lib/auth-server + +notifier: + gmail: + username: user@example.com + password: yourpassword + diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 00074863b..2352c93fc 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -7,3 +7,10 @@ services: - ./src/views:/usr/src/views - ./src/public_html:/usr/src/public_html + ldap-admin: + image: osixia/phpldapadmin:0.6.11 + ports: + - 9090:80 + environment: + - PHPLDAPADMIN_LDAP_HOSTS=ldap + - PHPLDAPADMIN_HTTPS=false diff --git a/docker-compose.yml b/docker-compose.yml index 1c18c8329..8f62215fd 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,33 +3,30 @@ version: '2' services: auth: build: . - environment: - - LDAP_URL=ldap://ldap - - LDAP_USERS_DN=dc=example,dc=com - - TOTP_SECRET=GRWGIJS6IRHVEODVNRCXCOBMJ5AGC6ZE - - SESSION_SECRET=unsecure_secret - - SESSION_EXPIRATION_TIME=3600000 - - STORE_DIRECTORY=/var/lib/auth-server depends_on: - ldap restart: always + volumes: + - ./config.yml:/etc/auth-server/config.yml:ro ldap: - image: osixia/openldap:1.1.7 + image: dinkel/openldap environment: - - LDAP_ORGANISATION=MyCompany - - LDAP_DOMAIN=example.com - - LDAP_ADMIN_PASSWORD=password + - SLAPD_ORGANISATION=MyCompany + - SLAPD_DOMAIN=example.com + - SLAPD_PASSWORD=password expose: - "389" + volumes: + - ./example/ldap:/etc/ldap.dist/prepopulate nginx: image: nginx:alpine volumes: - - ./nginx_conf/nginx.conf:/etc/nginx/nginx.conf - - ./nginx_conf/index.html:/usr/share/nginx/html/index.html - - ./nginx_conf/secret.html:/usr/share/nginx/html/secret.html - - ./nginx_conf/ssl:/etc/ssl + - ./example/nginx_conf/nginx.conf:/etc/nginx/nginx.conf + - ./example/nginx_conf/index.html:/usr/share/nginx/html/index.html + - ./example/nginx_conf/secret.html:/usr/share/nginx/html/secret.html + - ./example/nginx_conf/ssl:/etc/ssl depends_on: - auth ports: diff --git a/example/ldap/base.ldif b/example/ldap/base.ldif new file mode 100644 index 000000000..ba1f727d9 --- /dev/null +++ b/example/ldap/base.ldif @@ -0,0 +1,31 @@ +dn: ou=groups,dc=example,dc=com +objectclass: organizationalUnit +objectclass: top +ou: groups + +dn: ou=users,dc=example,dc=com +objectclass: organizationalUnit +objectclass: top +ou: users + +dn: cn=user,ou=groups,dc=example,dc=com +cn: user +gidnumber: 502 +objectclass: posixGroup +objectclass: top + +dn: cn=user,ou=users,dc=example,dc=com +cn: user +gidnumber: 500 +givenname: user +homedirectory: /home/user1 +loginshell: /bin/sh +objectclass: inetOrgPerson +objectclass: posixAccount +objectclass: top +mail: user@example.com +sn: User +uid: user +uidnumber: 1000 +userpassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= + diff --git a/nginx_conf/index.html b/example/nginx_conf/index.html similarity index 100% rename from nginx_conf/index.html rename to example/nginx_conf/index.html diff --git a/nginx_conf/nginx.conf b/example/nginx_conf/nginx.conf similarity index 100% rename from nginx_conf/nginx.conf rename to example/nginx_conf/nginx.conf diff --git a/nginx_conf/secret.html b/example/nginx_conf/secret.html similarity index 100% rename from nginx_conf/secret.html rename to example/nginx_conf/secret.html diff --git a/nginx_conf/ssl/server.crt b/example/nginx_conf/ssl/server.crt similarity index 100% rename from nginx_conf/ssl/server.crt rename to example/nginx_conf/ssl/server.crt diff --git a/nginx_conf/ssl/server.csr b/example/nginx_conf/ssl/server.csr similarity index 100% rename from nginx_conf/ssl/server.csr rename to example/nginx_conf/ssl/server.csr diff --git a/nginx_conf/ssl/server.key b/example/nginx_conf/ssl/server.key similarity index 100% rename from nginx_conf/ssl/server.key rename to example/nginx_conf/ssl/server.key diff --git a/package.json b/package.json index ac5bb0573..bb9763798 100644 --- a/package.json +++ b/package.json @@ -28,12 +28,16 @@ "express-session": "^1.14.2", "ldapjs": "^1.0.1", "nedb": "^1.8.0", + "nodemailer": "^2.7.0", "object-path": "^0.11.3", + "randomstring": "^1.1.5", "speakeasy": "^2.0.0", - "winston": "^2.3.1" + "winston": "^2.3.1", + "yamljs": "^0.2.8" }, "devDependencies": { "mocha": "^3.2.0", + "mockdate": "^2.0.1", "request": "^2.79.0", "should": "^11.1.1", "sinon": "^1.17.6", diff --git a/src/index.js b/src/index.js index 9ba2c02fa..4e9a10385 100644 --- a/src/index.js +++ b/src/index.js @@ -3,15 +3,25 @@ var server = require('./lib/server'); var ldap = require('ldapjs'); var u2f = require('authdog'); +var YAML = require('yamljs'); + +var config_path = process.argv[2]; +console.log('Parse configuration file: %s', config_path); + +var yaml_config = YAML.load(config_path); var config = { port: process.env.PORT || 8080, - totp_secret: process.env.TOTP_SECRET, - 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 - store_directory: process.env.STORE_DIRECTORY + 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, + session_secret: yaml_config.session.secret, + session_max_age: yaml_config.session.expiration || 3600000, // in ms + store_directory: yaml_config.store_directory, + gmail: { + user: yaml_config.notifier.gmail.username, + pass: yaml_config.notifier.gmail.password + } } var ldap_client = ldap.createClient({ diff --git a/src/lib/email_sender.js b/src/lib/email_sender.js new file mode 100644 index 000000000..8f25e841c --- /dev/null +++ b/src/lib/email_sender.js @@ -0,0 +1,25 @@ + +module.exports = EmailSender; + +var Promise = require('bluebird'); + +function EmailSender(nodemailer, options) { + var transporter = nodemailer.createTransport({ + service: 'gmail', + auth: { + user: options.gmail.user, + pass: options.gmail.pass + } + }); + this.transporter = Promise.promisifyAll(transporter); +} + +EmailSender.prototype.send = function(to, subject, html) { + var mailOptions = {}; + mailOptions.from = 'auth-server@open-intent.io'; + mailOptions.to = to; + mailOptions.subject = subject; + mailOptions.html = html; + return this.transporter.sendMailAsync(mailOptions); +} + diff --git a/src/lib/exceptions.js b/src/lib/exceptions.js new file mode 100644 index 000000000..7b7a1d98d --- /dev/null +++ b/src/lib/exceptions.js @@ -0,0 +1,17 @@ + +module.exports = { + LdapSearchError: LdapSearchError, + LdapBindError: LdapBindError, +} + +function LdapSearchError(message) { + this.name = "LdapSearchError"; + this.message = (message || ""); +} +LdapSearchError.prototype = Object.create(Error.prototype); + +function LdapBindError(message) { + this.name = "LdapBindError"; + this.message = (message || ""); +} +LdapBindError.prototype = Object.create(Error.prototype); diff --git a/src/lib/ldap.js b/src/lib/ldap.js index c2e36156b..b267cfe98 100644 --- a/src/lib/ldap.js +++ b/src/lib/ldap.js @@ -1,13 +1,46 @@ module.exports = { - 'validate': validateCredentials + validate: validateCredentials, + get_email: retrieve_email } var util = require('util'); var Promise = require('bluebird'); +var exceptions = require('./exceptions'); 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 }); - return bind_promised(userDN, password); + return bind_promised(userDN, password) + .error(function(err) { + throw new exceptions.LdapBindError(err.message); + }); +} + +function retrieve_email(ldap_client, username, users_dn) { + var userDN = util.format("cn=%s,%s", username, users_dn); + var search_promised = Promise.promisify(ldap_client.search, { context: ldap_client }); + var query = {}; + query.sizeLimit = 1; + query.attributes = ['mail']; + var base_dn = userDN; + + return new Promise(function(resolve, reject) { + search_promised(base_dn, query) + .then(function(res) { + var doc; + res.on('searchEntry', function(entry) { + doc = entry.object; + }); + res.on('error', function(err) { + reject(new exceptions.LdapSearchError(err.message)); + }); + res.on('end', function(result) { + resolve(doc); + }); + }) + .catch(function(err) { + reject(new exceptions.LdapSearchError(err.message)); + }); + }); } diff --git a/src/lib/routes.js b/src/lib/routes.js index b9e46e202..b74de5db0 100644 --- a/src/lib/routes.js +++ b/src/lib/routes.js @@ -2,6 +2,7 @@ var first_factor = require('./routes/first_factor'); var second_factor = require('./routes/second_factor'); var verify = require('./routes/verify'); +var objectPath = require('object-path'); module.exports = { login: serveLogin, @@ -12,9 +13,11 @@ module.exports = { } function serveLogin(req, res) { - req.session.auth_session = {}; - req.session.auth_session.first_factor = false; - req.session.auth_session.second_factor = false; + if(!(objectPath.has(req, 'session.auth_session'))) { + req.session.auth_session = {}; + 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 4588a9749..1551be6cd 100644 --- a/src/lib/routes/first_factor.js +++ b/src/lib/routes/first_factor.js @@ -1,6 +1,7 @@ module.exports = first_factor; +var exceptions = require('../exceptions'); var ldap = require('../ldap'); var objectPath = require('object-path'); @@ -10,7 +11,9 @@ 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); } @@ -23,19 +26,40 @@ function first_factor(req, res) { return; } + logger.info('1st factor: Starting authentication of user "%s"', username); + var ldap_client = req.app.get('ldap client'); var config = req.app.get('config'); + 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; + 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: Retrieved email is %s', email); + req.session.auth_session.email = email; res.status(204); res.send(); - console.log('LDAP binding successful'); + }) + .catch(exceptions.LdapSearchError, function(err) { + logger.info('1st factor: Unable to retrieve email from LDAP'); + res.status(500); + res.send(); + }) + .catch(exceptions.LdapBindError, function(err) { + logger.info('1st factor: LDAP binding failed'); + replyWithUnauthorized(res); }) .catch(function(err) { - replyWithUnauthorized(res); - console.log('LDAP binding failed:', err); + logger.debug('1st factor: Unhandled error %s', err); }); } diff --git a/src/lib/routes/second_factor.js b/src/lib/routes/second_factor.js index 1c5f72e4e..f57149dd0 100644 --- a/src/lib/routes/second_factor.js +++ b/src/lib/routes/second_factor.js @@ -5,8 +5,11 @@ var u2f = require('./u2f'); module.exports = { totp: denyNotLogged(require('./totp')), u2f: { - register_request: denyNotLogged(u2f.register_request), - register: denyNotLogged(u2f.register), + register_request: u2f.register_request, + register: u2f.register, + register_handler_get: u2f.register_handler_get, + register_handler_post: u2f.register_handler_post, + sign_request: denyNotLogged(u2f.sign_request), sign: denyNotLogged(u2f.sign), } diff --git a/src/lib/routes/u2f.js b/src/lib/routes/u2f.js index 0f504fa25..33f423d73 100644 --- a/src/lib/routes/u2f.js +++ b/src/lib/routes/u2f.js @@ -1,89 +1,22 @@ +var u2f_register = require('./u2f_register'); +var u2f_common = require('./u2f_common'); + module.exports = { - register_request: register_request, - register: register, + register_request: u2f_register.register_request, + register: u2f_register.register, + register_handler_get: u2f_register.register_handler_get, + register_handler_post: u2f_register.register_handler_post, + sign_request: sign_request, sign: sign, } var objectPath = require('object-path'); -var util = require('util'); - -function replyWithInternalError(res, msg) { - res.status(500); - res.send(msg) -} - -function replyWithMissingRegistration(res) { - res.status(401); - res.send('Please register before authenticate'); -} - -function replyWithUnauthorized(res) { - res.status(401); - 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 appid = extractAppId(req); - - logger.debug('U2F register_request: headers=%s', JSON.stringify(req.headers)); - logger.info('U2F register_request: Starting registration'); - u2f.startRegistration(appid, []) - .then(function(registrationRequest) { - logger.info('U2F register_request: Sending back registration request'); - req.session.auth_session.register_request = registrationRequest; - res.status(200); - res.json(registrationRequest); - }, function(err) { - logger.error('U2F register_request: %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; - } - - 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)); - - 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_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'); - }); -} function retrieveU2fMeta(req, user_data_storage) { var userid = req.session.auth_session.userid; - var appid = extractAppId(req); + var appid = u2f_common.extract_app_id(req); return user_data_storage.get_u2f_meta(userid, appid); } @@ -116,13 +49,13 @@ function sign_request(req, res) { retrieveU2fMeta(req, user_data_storage) .then(function(doc) { if(!doc) { - replyWithMissingRegistration(res); + u2f_common.reply_with_missing_registration(res); return; } var u2f = req.app.get('u2f'); var meta = doc.meta; - var appid = extractAppId(req); + var appid = u2f_common.extract_app_id(req); logger.info('U2F sign_request: Start authentication'); return startU2fAuthentication(u2f, appid, meta); }) @@ -134,14 +67,14 @@ function sign_request(req, res) { }) .catch(function(err) { logger.info('U2F sign_request: %s', err); - replyWithUnauthorized(res); + u2f_common.reply_with_unauthorized(res); }); } function sign(req, res) { if(!objectPath.has(req, 'session.auth_session.sign_request')) { - replyWithUnauthorized(res); + u2f_common.reply_with_unauthorized(res); return; } @@ -150,7 +83,7 @@ function sign(req, res) { retrieveU2fMeta(req, user_data_storage) .then(function(doc) { - var appid = extractAppId(req); + 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; diff --git a/src/lib/routes/u2f_common.js b/src/lib/routes/u2f_common.js new file mode 100644 index 000000000..4b7e46020 --- /dev/null +++ b/src/lib/routes/u2f_common.js @@ -0,0 +1,38 @@ + +module.exports = { + extract_app_id: extract_app_id, + extract_original_url: extract_original_url, + extract_referrer: extract_referrer, + reply_with_internal_error: reply_with_internal_error, + reply_with_missing_registration: reply_with_missing_registration, + reply_with_unauthorized: reply_with_unauthorized +} + +var util = require('util'); + +function extract_app_id(req) { + return util.format('https://%s', req.headers.host); +} + +function extract_original_url(req) { + return util.format('https://%s%s', req.headers.host, req.headers['x-original-uri']); +} + +function extract_referrer(req) { + return req.headers.referrer; +} + +function reply_with_internal_error(res, msg) { + res.status(500); + res.send(msg) +} + +function reply_with_missing_registration(res) { + res.status(401); + res.send('Please register before authenticate'); +} + +function reply_with_unauthorized(res) { + res.status(401); + res.send(); +} diff --git a/src/lib/routes/u2f_register.js b/src/lib/routes/u2f_register.js new file mode 100644 index 000000000..50785232a --- /dev/null +++ b/src/lib/routes/u2f_register.js @@ -0,0 +1,71 @@ + +var u2f_register_handler = require('./u2f_register_handler'); + +module.exports = { + register_request: register_request, + register: register, + register_handler_get: u2f_register_handler.get, + register_handler_post: u2f_register_handler.post +} + +var objectPath = require('object-path'); +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 appid = u2f_common.extract_app_id(req); + + logger.debug('U2F register_request: headers=%s', JSON.stringify(req.headers)); + logger.info('U2F register_request: Starting registration'); + u2f.startRegistration(appid, []) + .then(function(registrationRequest) { + logger.info('U2F register_request: Sending back registration request'); + req.session.auth_session.register_request = registrationRequest; + res.status(200); + res.json(registrationRequest); + }) + .catch(function(err) { + logger.error('U2F register_request: %s', err); + u2f_common.reply_with_internal_error(res, 'Unable to complete the registration'); + }); +} + +function register(req, res) { + if(!objectPath.has(req, 'session.auth_session.register_request')) { + u2f_common.reply_with_unauthorized(res); + 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'); + + 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 + } + return user_data_storage.set_u2f_meta(userid, appid, meta); + }) + .then(function() { + res.status(204); + res.send(); + }) + .catch(function(err) { + logger.error('U2F register: %s', err); + u2f_common.reply_with_unauthorized(res); + }); +} + diff --git a/src/lib/routes/u2f_register_handler.js b/src/lib/routes/u2f_register_handler.js new file mode 100644 index 000000000..3e99c8fa6 --- /dev/null +++ b/src/lib/routes/u2f_register_handler.js @@ -0,0 +1,93 @@ + +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'); + +function register_handler_get(req, res) { + var logger = req.app.get('logger'); + logger.info('U2F register_handler: Continue registration process'); + + var registration_token = objectPath.get(req, 'query.registration_token'); + logger.debug('U2F register_handler: registration_token=%s', registration_token); + + if(!registration_token) { + res.status(403); + res.send(); + return; + } + + var user_data_store = req.app.get('user data store'); + + logger.debug('U2F register_handler: verify token validity'); + user_data_store.verify_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(!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(); + }); +} diff --git a/src/lib/server.js b/src/lib/server.js index ee02d70e4..fe2394fc6 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -12,7 +12,9 @@ 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'); function run(config, ldap_client, u2f, fn) { var view_directory = path.resolve(__dirname, '../views'); @@ -20,6 +22,9 @@ function run(config, ldap_client, u2f, fn) { var datastore_options = {}; datastore_options.directory = config.store_directory; + var email_options = {}; + email_options.gmail = config.gmail; + var app = express(); app.use(express.static(public_html_directory)); app.use(bodyParser.urlencoded({ extended: false })); @@ -46,18 +51,26 @@ function run(config, ldap_client, u2f, fn) { 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('config', config); + // web pages app.get ('/login', routes.login); app.get ('/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); + + // verify authentication app.get ('/verify', routes.verify); + // Authentication process app.post ('/1stfactor', routes.first_factor); app.post ('/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 ('/2ndfactor/u2f/sign_request', routes.second_factor.u2f.sign_request); app.post ('/2ndfactor/u2f/sign', routes.second_factor.u2f.sign); diff --git a/src/lib/user_data_store.js b/src/lib/user_data_store.js index 5c7b363fe..b44e00af0 100644 --- a/src/lib/user_data_store.js +++ b/src/lib/user_data_store.js @@ -5,15 +5,20 @@ var Promise = require('bluebird'); 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); +} + +function create_collection(name, options, DataStore) { var datastore_options = {}; + if(options.directory) - datastore_options.filename = path.resolve(options.directory, 'u2f_meta'); + datastore_options.filename = path.resolve(options.directory, name); datastore_options.inMemoryOnly = options.inMemoryOnly || false; datastore_options.autoload = true; - console.log(datastore_options); - - this._u2f_meta_collection = Promise.promisifyAll(new DataStore(datastore_options)); + return Promise.promisifyAll(new DataStore(datastore_options)); } UserDataStore.prototype.set_u2f_meta = function(userid, app_id, meta) { @@ -37,3 +42,31 @@ 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) { + var newDocument = {}; + newDocument.userid = userid; + newDocument.token = token; + newDocument.max_date = new Date(new Date().getTime() + max_age); + + return this._u2f_registration_tokens_collection.insertAsync(newDocument); +} + +UserDataStore.prototype.verify_u2f_registration_token = function(token) { + var query = {}; + query.token = token; + + return this._u2f_registration_tokens_collection.findOneAsync(query) + .then(function(doc) { + 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'); + } + + return Promise.resolve(); + }); +} diff --git a/src/public_html/img/pendrive.png b/src/public_html/img/pendrive.png new file mode 100644 index 0000000000000000000000000000000000000000..fa49178c326631167ec642aaacc117c38b31f6bf GIT binary patch literal 6721 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 Hcm4kWRQYbi literal 0 HcmV?d00001 diff --git a/src/public_html/login.js b/src/public_html/login.js index 55df039dc..7c0e22cd2 100644 --- a/src/public_html/login.js +++ b/src/public_html/login.js @@ -42,27 +42,39 @@ function onTotpSignButtonClicked() { } function onU2fSignButtonClicked() { - startSecondFactorU2fSigning(function(err) { + startU2fAuthentication(function(err) { if(err) { - onSecondFactorU2fSigningFailure(); + onU2fAuthenticationFailure(); return; } - onSecondFactorU2fSigningSuccess(); + onU2fAuthenticationSuccess(); }, 120); } -function onU2fRegisterButtonClicked() { - startSecondFactorU2fRegister(function(err) { +function onU2fRegistrationButtonClicked() { + askForU2fRegistration(function(err) { if(err) { - onSecondFactorU2fRegisterFailure(); + $.notify('Unable to send you an email', 'error'); return; } - onSecondFactorU2fRegisterSuccess(); - }, 120); + $.notify('An email has been sent to your email address', 'info'); + }); } -function finishSecondFactorU2f(url, responseData, fn) { - console.log(responseData); +function askForU2fRegistration(fn) { + $.ajax({ + type: 'POST', + url: '/auth/u2f-register' + }) + .done(function(data) { + fn(undefined, data); + }) + .fail(function(xhr, status) { + fn(status); + }); +} + +function finishU2fAuthentication(url, responseData, fn) { $.ajax({ type: 'POST', url: url, @@ -78,19 +90,11 @@ function finishSecondFactorU2f(url, responseData, fn) { }); } -function startSecondFactorU2fSigning(fn, timeout) { +function startU2fAuthentication(fn, timeout) { $.get('/auth/2ndfactor/u2f/sign_request', {}, null, 'json') .done(function(signResponse) { var registeredKeys = signResponse.registeredKeys; - $.notify('Please touch the token', 'information'); - console.log(signResponse); - - // Store sessionIds - // var sessionIds = {}; - // for (var i = 0; i < registeredKeys.length; i++) { - // sessionIds[registeredKeys[i].keyHandle] = registeredKeys[i].sessionId; - // delete registeredKeys[i]['sessionId']; - // } + $.notify('Please touch the token', 'info'); u2f.sign( signResponse.appId, @@ -100,8 +104,7 @@ function startSecondFactorU2fSigning(fn, timeout) { if (response.errorCode) { fn(response); } else { - // response['sessionId'] = sessionIds[response.keyHandle]; - finishSecondFactorU2f('/auth/2ndfactor/u2f/sign', response, fn); + finishU2fAuthentication('/auth/2ndfactor/u2f/sign', response, fn); } }, timeout @@ -112,28 +115,6 @@ function startSecondFactorU2fSigning(fn, timeout) { }); } -function startSecondFactorU2fRegister(fn, timeout) { - $.get('/auth/2ndfactor/u2f/register_request', {}, null, 'json') - .done(function(startRegisterResponse) { - console.log(startRegisterResponse); - $.notify('Please touch the token', 'information'); - u2f.register( - startRegisterResponse.appId, - startRegisterResponse.registerRequests, - startRegisterResponse.registeredKeys, - function (response) { - if (response.errorCode) { - fn(response.errorCode); - } else { - // response['sessionId'] = startRegisterResponse.clientData; - finishSecondFactorU2f('/auth/2ndfactor/u2f/register', response, fn); - } - }, - timeout - ); - }); -} - function validateSecondFactorTotp(token, fn) { $.post('/auth/2ndfactor/totp', { token: token, @@ -146,7 +127,6 @@ function validateSecondFactorTotp(token, fn) { }); } - function validateFirstFactor(username, password, fn) { $.post('/auth/1stfactor', { username: username, @@ -193,21 +173,11 @@ function onSecondFactorTotpFailure() { $.notify('Wrong TOTP token', 'error'); } -function onSecondFactorU2fSigningSuccess() { +function onU2fAuthenticationSuccess() { onAuthenticationSuccess(); } -function onSecondFactorU2fSigningFailure(err) { - console.error(err); - $.notify('Problem authenticating with U2F.', 'error'); -} - -function onSecondFactorU2fRegisterSuccess() { - $.notify('Registration succeeded. You can now sign in.', 'success'); -} - -function onSecondFactorU2fRegisterFailure(err) { - console.error(err); +function onU2fAuthenticationFailure(err) { $.notify('Problem authenticating with U2F.', 'error'); } @@ -247,26 +217,23 @@ function setupU2fSignButton() { setupEnterKeypressListener('#u2f', onU2fSignButtonClicked); } -function setupU2fRegisterButton() { - $('#second-factor #u2f-register-button').on('click', onU2fRegisterButtonClicked); - setupEnterKeypressListener('#u2f', onU2fRegisterButtonClicked); +function setupU2fRegistrationButton() { + $('#second-factor #u2f-register-button').on('click', onU2fRegistrationButtonClicked); } function enterFirstFactor() { - // console.log('entering first factor'); showFirstFactorLayout(); hideSecondFactorLayout(); setupFirstFactorLoginButton(); } function enterSecondFactor() { - // console.log('entering second factor'); hideFirstFactorLayout(); showSecondFactorLayout(); cleanupFirstFactorLoginButton(); setupTotpSignButton(); setupU2fSignButton(); - setupU2fRegisterButton(); + setupU2fRegistrationButton(); } $(document).ready(function() { diff --git a/src/public_html/u2f-register.js b/src/public_html/u2f-register.js new file mode 100644 index 000000000..56b6721a9 --- /dev/null +++ b/src/public_html/u2f-register.js @@ -0,0 +1,68 @@ +(function() { + +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, + data: JSON.stringify(responseData), + contentType: 'application/json', + dataType: 'json', + }) + .done(function(data) { + fn(undefined, data); + }) + .fail(function(xhr, status) { + $.notify('Error when finish U2F transaction' + status); + }); +} + +function startRegister(fn, timeout) { + $.get('/auth/2ndfactor/u2f/register_request', {}, null, 'json') + .done(function(startRegisterResponse) { + u2f.register( + startRegisterResponse.appId, + startRegisterResponse.registerRequests, + startRegisterResponse.registeredKeys, + function (response) { + if (response.errorCode) { + fn(response.errorCode); + } else { + finishRegister('/auth/2ndfactor/u2f/register', response, fn); + } + }, + timeout + ); + }); +} + +function redirect() { + var redirect_uri = '/'; + if('redirect' in params) { + redirect_uri = params['redirect']; + } + window.location.replace(redirect_uri); +} + +function onRegisterSuccess() { + redirect(); +} + +function onRegisterFailure(err) { + $.notify('Problem authenticating with U2F.', 'error'); +} + +$(document).ready(function() { + startRegister(function(err) { + if(err) { + onRegisterFailure(err); + return; + } + onRegisterSuccess(); + }, 240); +}); + +})(); diff --git a/src/views/u2f_register.ejs b/src/views/u2f_register.ejs new file mode 100644 index 000000000..0bf8b353d --- /dev/null +++ b/src/views/u2f_register.ejs @@ -0,0 +1,17 @@ + + + FIDO U2F Registration + + + + + + + + + + + diff --git a/test/integration/test_server.js b/test/integration/test_server.js index 29d6a7da5..315f3c8bf 100644 --- a/test/integration/test_server.js +++ b/test/integration/test_server.js @@ -64,12 +64,12 @@ describe('test the server', function() { it('should fail the first_factor login', function() { return postPromised(BASE_URL + '/auth/1stfactor', { form: { - username: 'admin', + username: 'user', password: 'bad_password' } }) .then(function(data) { - assert.equal(401, data.statusCode); + assert.equal(data.statusCode, 401); return Promise.resolve(); }); }); @@ -82,7 +82,7 @@ describe('test the server', function() { return postPromised(BASE_URL + '/auth/1stfactor', { form: { - username: 'admin', + username: 'user', password: 'password', } }) diff --git a/test/unitary/routes/test_first_factor.js b/test/unitary/routes/test_first_factor.js index d4e4c2d7e..840a9b99c 100644 --- a/test/unitary/routes/test_first_factor.js +++ b/test/unitary/routes/test_first_factor.js @@ -2,24 +2,40 @@ var sinon = require('sinon'); var Promise = require('bluebird'); var assert = require('assert'); +var winston = require('winston'); var first_factor = require('../../../src/lib/routes/first_factor'); describe('test the first factor validation route', function() { var req, res; var ldap_interface_mock; + var search_res_ok; beforeEach(function() { - var bind_mock = sinon.stub(); ldap_interface_mock = { - bind: bind_mock + bind: sinon.stub(), + search: sinon.stub() } var config = { ldap_users_dn: 'dc=example,dc=com' } + var search_doc = { + object: { + mail: 'test_ok@example.com' + } + }; + + var search_res_ok = {}; + search_res_ok.on = sinon.spy(function(event, fn) { + if(event != 'error') fn(search_doc); + }); + ldap_interface_mock.search.yields(undefined, search_res_ok); + var app_get = sinon.stub(); app_get.withArgs('ldap client').returns(ldap_interface_mock); app_get.withArgs('config').returns(ldap_interface_mock); + app_get.withArgs('logger').returns(winston); + req = { app: { get: app_get @@ -63,6 +79,18 @@ describe('test the first factor validation route', function() { first_factor(req, res); }); }); + + it('should return status code 500 when LDAP binding fails', function() { + return new Promise(function(resolve, reject) { + res.send = sinon.spy(function(data) { + assert.equal(500, res.status.getCall(0).args[0]); + resolve(); + }); + ldap_interface_mock.bind.yields(undefined); + ldap_interface_mock.search.yields('error'); + first_factor(req, res); + }); + }); }); diff --git a/test/unitary/routes/test_u2f.js b/test/unitary/routes/test_u2f.js index 4d28ee04f..2d939d4ed 100644 --- a/test/unitary/routes/test_u2f.js +++ b/test/unitary/routes/test_u2f.js @@ -95,7 +95,7 @@ describe('test u2f routes', function() { u2f.register(req, res); }); - it('should return unauthorized error on registration request', function(done) { + it('should return unauthorized on finishRegistration error', function(done) { res.send = sinon.spy(function(data) { assert.equal(401, res.status.getCall(0).args[0]); done(); @@ -105,6 +105,7 @@ describe('test u2f routes', function() { u2f_mock.finishRegistration = sinon.stub(); u2f_mock.finishRegistration.returns(Promise.reject('Internal error')); + req.session.auth_session.register_request = 'abc'; req.app.get.withArgs('u2f').returns(u2f_mock); u2f.register(req, res); }); diff --git a/test/unitary/routes/test_u2f_register.js b/test/unitary/routes/test_u2f_register.js new file mode 100644 index 000000000..d2c6b2953 --- /dev/null +++ b/test/unitary/routes/test_u2f_register.js @@ -0,0 +1,115 @@ + +var sinon = require('sinon'); +var winston = require('winston'); +var u2f_register = require('../../../src/lib/routes/u2f_register'); +var assert = require('assert'); + +describe('test register handle', function() { + var req, res; + var user_data_store; + + beforeEach(function() { + req = {} + 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.save_u2f_registration_token = sinon.stub().returns(Promise.resolve({})); + user_data_store.verify_u2f_registration_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 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]); + 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]); + 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(); + }); + 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]); + 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); + 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.verify_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 615ed627f..80dc876b1 100644 --- a/test/unitary/test_data_persistence.js +++ b/test/unitary/test_data_persistence.js @@ -17,7 +17,8 @@ describe('test data persistence', function() { var u2f; var tmpDir; var ldap_client = { - bind: sinon.stub() + bind: sinon.stub(), + search: sinon.stub() }; var config; @@ -28,10 +29,23 @@ describe('test data persistence', function() { u2f.startAuthentication = sinon.stub(); u2f.finishAuthentication = sinon.stub(); + var search_doc = { + object: { + mail: 'test_ok@example.com' + } + }; + + var search_res = {}; + search_res.on = sinon.spy(function(event, fn) { + if(event != 'error') fn(search_doc); + }); + 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'); + ldap_client.search.yields(undefined, search_res); + tmpDir = tmp.dirSync({ unsafeCleanup: true }); config = { port: PORT, @@ -40,7 +54,8 @@ describe('test data persistence', function() { ldap_users_dn: 'ou=users,dc=example,dc=com', session_secret: 'session_secret', session_max_age: 50000, - store_directory: tmpDir.name + store_directory: tmpDir.name, + gmail: { user: 'user@example.com', pass: 'password' } }; }); @@ -160,7 +175,7 @@ describe('test data persistence', function() { } function execute_verification(jar) { - return request.getAsync({ url: BASE_URL + '/_verify', jar: jar }) + return request.getAsync({ url: BASE_URL + '/verify', jar: jar }) } function execute_login(jar) { diff --git a/test/unitary/test_email_sender.js b/test/unitary/test_email_sender.js new file mode 100644 index 000000000..9328d674d --- /dev/null +++ b/test/unitary/test_email_sender.js @@ -0,0 +1,31 @@ + + +var sinon = require('sinon'); +var assert = require('assert'); +var EmailSender = require('../../src/lib/email_sender'); + +describe('test email sender', function() { + it('should send an email', function() { + var nodemailer = {}; + var transporter = {}; + nodemailer.createTransport = sinon.stub().returns(transporter); + transporter.sendMail = sinon.stub().yields(); + var options = {}; + options.gmail = {}; + options.gmail.user = 'test@gmail.com'; + options.gmail.pass = 'test@gmail.com'; + + var sender = new EmailSender(nodemailer, options); + var to = 'example@gmail.com'; + var subject = 'subject'; + var content = 'content'; + + return sender.send(to, subject, content) + .then(function() { + assert.equal(to, transporter.sendMail.getCall(0).args[0].to); + assert.equal(subject, transporter.sendMail.getCall(0).args[0].subject); + assert.equal(content, transporter.sendMail.getCall(0).args[0].html); + return Promise.resolve(); + }); + }); +}); diff --git a/test/unitary/test_ldap.js b/test/unitary/test_ldap.js index 1ded5ff74..5aa3c2a37 100644 --- a/test/unitary/test_ldap.js +++ b/test/unitary/test_ldap.js @@ -10,43 +10,80 @@ describe('test ldap validation', function() { beforeEach(function() { ldap_client = { - bind: sinon.stub() + bind: sinon.stub(), + search: sinon.stub() } }); - function test_validate() { + describe('test binding', test_binding); + describe('test get email', test_get_email); + + function test_binding() { + function test_validate() { var username = 'user'; var password = 'password'; var ldap_url = 'http://ldap'; var users_dn = 'dc=example,dc=com'; return ldap.validate(ldap_client, username, password, ldap_url, users_dn); + } + + it('should bind the user if good credentials provided', function() { + ldap_client.bind.yields(); + return test_validate(); + }); + + // cover an issue with promisify context + it('should promisify correctly', function() { + function LdapClient() { + this.test = 'abc'; + } + LdapClient.prototype.bind = function(username, password, fn) { + assert.equal('abc', this.test); + fn(); + } + ldap_client = new LdapClient(); + return test_validate(); + }); + + it('should not bind the user if wrong credentials provided', function() { + ldap_client.bind.yields('wrong credentials'); + var promise = test_validate(); + return promise.catch(function() { + return Promise.resolve(); + }); + }); } + function test_get_email() { + it('should retrieve the email of an existing user', function() { + var expected_doc = {}; + expected_doc.object = {}; + expected_doc.object.mail = 'user@example.com'; + var res_emitter = {}; + res_emitter.on = sinon.spy(function(event, fn) { + if(event != 'error') fn(expected_doc) + }); - it('should bind the user if good credentials provided', function() { - ldap_client.bind.yields(); - return test_validate(); - }); + ldap_client.search.yields(undefined, res_emitter); - // cover an issue with promisify context - it('should promisify correctly', function() { - function LdapClient() { - this.test = 'abc'; - } - LdapClient.prototype.bind = function(username, password, fn) { - assert.equal('abc', this.test); - fn(); - } - ldap_client = new LdapClient(); - return test_validate(); - }); - - it('should not bind the user if wrong credentials provided', function() { - ldap_client.bind.yields('wrong credentials'); - var promise = test_validate(); - return promise.catch(function() { - return Promise.resolve(); + return ldap.get_email(ldap_client, 'user', 'dc=example,dc=com') + .then(function(doc) { + assert.deepEqual(doc, expected_doc.object); + return Promise.resolve(); + }) }); - }); + + it('should fail on error with search method', function(done) { + var expected_doc = {}; + expected_doc.mail = []; + expected_doc.mail.push('user@example.com'); + ldap_client.search.yields('error'); + + ldap.get_email(ldap_client, 'user', 'dc=example,dc=com') + .catch(function() { + done(); + }) + }); + } }); diff --git a/test/unitary/test_server.js b/test/unitary/test_server.js index 404577f26..88144a865 100644 --- a/test/unitary/test_server.js +++ b/test/unitary/test_server.js @@ -15,7 +15,8 @@ describe('test the server', function() { var _server var u2f; var ldap_client = { - bind: sinon.stub() + bind: sinon.stub(), + search: sinon.stub() }; beforeEach(function(done) { @@ -25,7 +26,11 @@ describe('test the server', function() { 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 + session_max_age: 50000, + gmail: { + user: 'user@example.com', + pass: 'password' + } }; u2f = {}; @@ -34,8 +39,20 @@ describe('test the server', function() { u2f.startAuthentication = sinon.stub(); u2f.finishAuthentication = sinon.stub(); + var search_doc = { + object: { + mail: 'test_ok@example.com' + } + }; + + var search_res = {}; + search_res.on = sinon.spy(function(event, fn) { + if(event != 'error') fn(search_doc); + }); + ldap_client.bind.withArgs('cn=test_ok,ou=users,dc=example,dc=com', 'password').yields(undefined); + ldap_client.search.yields(undefined, search_res); ldap_client.bind.withArgs('cn=test_nok,ou=users,dc=example,dc=com', 'password').yields('error'); _server = server.run(config, ldap_client, u2f, function() { @@ -126,6 +143,51 @@ describe('test the server', function() { }); }); + it('should keep session variables when login page is reloaded', function() { + var real_token = speakeasy.totp({ + secret: 'totp_secret', + encoding: 'base32' + }); + var j = request.jar(); + return request.getAsync({ url: BASE_URL + '/login', jar: j }) + .then(function(res) { + assert.equal(res.statusCode, 200, 'get login page failed'); + return request.postAsync({ + url: BASE_URL + '/1stfactor', + jar: j, + form: { + username: 'test_ok', + password: 'password' + } + }); + }) + .then(function(res) { + assert.equal(res.statusCode, 204, 'first factor failed'); + return request.postAsync({ + url: BASE_URL + '/2ndfactor/totp', + jar: j, + form: { + token: real_token + } + }); + }) + .then(function(res) { + assert.equal(res.statusCode, 204, 'second factor failed'); + return request.getAsync({ url: BASE_URL + '/login', jar: j }) + }) + .then(function(res) { + assert.equal(res.statusCode, 200, 'login page loading failed'); + return request.getAsync({ url: BASE_URL + '/verify', jar: j }) + }) + .then(function(res) { + assert.equal(res.statusCode, 204, 'verify failed'); + return Promise.resolve(); + }) + .catch(function(err) { + console.error(err); +  }); + }); + it('should return status code 204 when user is authenticated using u2f', function() { var sign_request = {}; var sign_status = {}; @@ -193,6 +255,5 @@ describe('test the server', function() { }); }); } - }); diff --git a/test/unitary/test_user_data_store.js b/test/unitary/test_user_data_store.js index f46b99417..f1b4750cb 100644 --- a/test/unitary/test_user_data_store.js +++ b/test/unitary/test_user_data_store.js @@ -3,8 +3,15 @@ var UserDataStore = require('../../src/lib/user_data_store'); var DataStore = require('nedb'); var assert = require('assert'); var Promise = require('bluebird'); +var sinon = require('sinon'); +var MockDate = require('mockdate'); describe('test user data store', function() { + describe('test u2f meta', test_u2f_meta); + describe('test u2f registration token', test_u2f_registration_token); +}); + +function test_u2f_meta() { it('should save a u2f meta', function() { var options = {}; options.inMemoryOnly = true; @@ -65,4 +72,91 @@ describe('test user data store', function() { return Promise.resolve(); }); }); -}); +} + +function test_u2f_registration_token() { + it('should save u2f registration token', function() { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var userid = 'user'; + var token = 'token'; + var max_age = 60; + + return data_store.save_u2f_registration_token(userid, token, max_age) + .then(function(document) { + assert.equal(userid, document.userid); + assert.equal(token, document.token); + assert('max_date' in document); + assert('_id' in document); + return Promise.resolve(); + }) + .catch(function(err) { + console.error(err); + return Promise.reject(err); + }); + }); + + it('should save u2f registration token and verify it', function(done) { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var userid = 'user'; + var token = 'token'; + var max_age = 50; + + data_store.save_u2f_registration_token(userid, token, max_age) + .then(function(document) { + return data_store.verify_u2f_registration_token(token); + }) + .then(function() { + done(); + }) + .catch(function(err) { + console.error(err); + }); + }); + + it('should fail when token does not exist', function() { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var token = 'token'; + + return data_store.verify_u2f_registration_token(token) + .then(function(document) { + return Promise.reject(); + }) + .catch(function(err) { + return Promise.resolve(err); + }); + }); + + it('should fail when token expired', function(done) { + var options = {}; + options.inMemoryOnly = true; + + var data_store = new UserDataStore(DataStore, options); + + var userid = 'user'; + var token = 'token'; + var max_age = 60; + MockDate.set('1/1/2000'); + + data_store.save_u2f_registration_token(userid, token, max_age) + .then(function() { + MockDate.set('1/2/2000'); + return data_store.verify_u2f_registration_token(token); + }) + .catch(function(err) { + MockDate.reset(); + done(); + }); + }); +}