From 9670b23a8b5e2ff93f7ff48f4f717d90556d666c Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 21 Jan 2017 17:41:06 +0100 Subject: [PATCH] Implement FIDO u2f authentication --- .travis.yml | 2 +- docker-compose.dev.yml | 8 + docker-compose.yml | 7 +- nginx_conf/nginx.conf | 26 +- nginx_conf/ssl/server.crt | 13 + nginx_conf/ssl/server.csr | 11 + nginx_conf/ssl/server.key | 15 + package.json | 12 +- src/index.js | 7 +- src/lib/authentication.js | 19 - src/lib/jwt.js | 32 - src/lib/ldap.js | 4 +- src/lib/replies.js | 27 - src/lib/routes.js | 34 +- src/lib/routes/deny_not_logged.js | 24 + src/lib/routes/first_factor.js | 19 +- src/lib/routes/second_factor.js | 16 + src/lib/routes/totp.js | 33 + src/lib/routes/u2f.js | 144 ++++ src/lib/routes/verify.js | 37 + src/lib/server.js | 37 +- src/lib/totp.js | 26 +- src/public_html/js.cookie.min.js | 2 - src/public_html/login.css | 32 +- src/public_html/login.js | 274 +++++-- src/public_html/notify.min.js | 1 + src/public_html/u2f-api.js | 748 ++++++++++++++++++ src/views/login.ejs | 33 +- test/integration/test_server.js | 93 ++- test/unitary/routes/test_deny_not_logged.js | 83 ++ test/unitary/routes/test_first_factor.js | 96 ++- test/unitary/routes/test_totp.js | 78 ++ test/unitary/routes/test_u2f.js | 230 ++++++ test/unitary/routes/test_verify.js | 63 ++ test/unitary/test_authentication.js | 105 --- test/unitary/test_jwt.js | 39 - test/unitary/test_ldap.js | 52 ++ test/unitary/test_ldap_checker.js | 35 - test/unitary/test_replies.js | 52 -- test/unitary/test_server.js | 269 ++++--- .../{test_totp_checker.js => test_totp.js} | 12 +- 41 files changed, 2187 insertions(+), 663 deletions(-) create mode 100644 docker-compose.dev.yml create mode 100644 nginx_conf/ssl/server.crt create mode 100644 nginx_conf/ssl/server.csr create mode 100644 nginx_conf/ssl/server.key delete mode 100644 src/lib/authentication.js delete mode 100644 src/lib/jwt.js delete mode 100644 src/lib/replies.js create mode 100644 src/lib/routes/deny_not_logged.js create mode 100644 src/lib/routes/second_factor.js create mode 100644 src/lib/routes/totp.js create mode 100644 src/lib/routes/u2f.js create mode 100644 src/lib/routes/verify.js delete mode 100644 src/public_html/js.cookie.min.js create mode 100644 src/public_html/notify.min.js create mode 100644 src/public_html/u2f-api.js create mode 100644 test/unitary/routes/test_deny_not_logged.js create mode 100644 test/unitary/routes/test_totp.js create mode 100644 test/unitary/routes/test_u2f.js create mode 100644 test/unitary/routes/test_verify.js delete mode 100644 test/unitary/test_authentication.js delete mode 100644 test/unitary/test_jwt.js create mode 100644 test/unitary/test_ldap.js delete mode 100644 test/unitary/test_ldap_checker.js delete mode 100644 test/unitary/test_replies.js rename test/unitary/{test_totp_checker.js => test_totp.js} (72%) diff --git a/.travis.yml b/.travis.yml index 84b9c1b98..b5f3617cf 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,7 +10,7 @@ script: - docker-compose build - docker-compose up -d - sleep 5 -- npm run-script integration-test +- npm run-script int-test deploy: provider: npm email: clement.michaud34@gmail.com diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 000000000..bd439c025 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,8 @@ + +version: '2' +services: + auth: + volumes: + - ./src/views:/usr/src/views + - ./src/public_html:/usr/src/public_html + diff --git a/docker-compose.yml b/docker-compose.yml index 23ceb2355..d9ea01a75 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -7,8 +7,8 @@ services: - LDAP_URL=ldap://ldap - LDAP_USERS_DN=dc=example,dc=com - TOTP_SECRET=GRWGIJS6IRHVEODVNRCXCOBMJ5AGC6ZE - - JWT_SECRET=unsecure_secret - - JWT_EXPIRATION_TIME=1h + - SESSION_SECRET=unsecure_secret + - SESSION_EXPIRATION_TIME=3600000 depends_on: - ldap restart: always @@ -28,7 +28,8 @@ services: - ./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 depends_on: - auth ports: - - "8080:80" + - "8080:443" diff --git a/nginx_conf/nginx.conf b/nginx_conf/nginx.conf index b50139f14..be07e2078 100644 --- a/nginx_conf/nginx.conf +++ b/nginx_conf/nginx.conf @@ -16,7 +16,6 @@ worker_processes 1; #pid logs/nginx.pid; - events { worker_connections 1024; } @@ -24,22 +23,28 @@ events { http { server { - listen 80; + listen 443 ssl; root /usr/share/nginx/html; + + server_name 127.0.0.1 localhost; + + ssl on; + ssl_certificate /etc/ssl/server.crt; + ssl_certificate_key /etc/ssl/server.key; error_page 401 = @error401; location @error401 { - return 302 http://localhost:8080/auth/login?redirect=$request_uri; + return 302 https://localhost:8080/auth/login?redirect=$request_uri; } - location = /check-auth { + location = /verify { internal; # proxy_pass_request_body off; 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/_auth; + proxy_pass http://auth/_verify; } location /auth/ { @@ -51,7 +56,7 @@ http { } location = /secret.html { - auth_request /check-auth; + auth_request /verify; auth_request_set $user $upstream_http_x_remote_user; proxy_set_header X-Forwarded-User $user; @@ -60,14 +65,5 @@ http { auth_request_set $expiry $upstream_http_remote_expiry; proxy_set_header Remote-Expiry $expiry; } - - - # Block everything but POST on _auth - location = /_auth { - if ($request_method != POST) { - return 403; - } - proxy_pass http://auth/_auth; - } } } diff --git a/nginx_conf/ssl/server.crt b/nginx_conf/ssl/server.crt new file mode 100644 index 000000000..abe89b561 --- /dev/null +++ b/nginx_conf/ssl/server.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICATCCAWoCCQCvH2RvyOshNzANBgkqhkiG9w0BAQsFADBFMQswCQYDVQQGEwJB +VTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0 +cyBQdHkgTHRkMB4XDTE3MDExNzIzMTc0M1oXDTE4MDExNzIzMTc0M1owRTELMAkG +A1UEBhMCQVUxEzARBgNVBAgMClNvbWUtU3RhdGUxITAfBgNVBAoMGEludGVybmV0 +IFdpZGdpdHMgUHR5IEx0ZDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEAzZaE +4XE1QyFNbrHBHRhSA53anAsJ5mBeG7Om6SdQcZAYahlDWEbtdoY4hy0gPNGcITcW +eE+WA+PvNRr7PczKEhneIyUUgV+nrz010fM5JnECPxLTe1oFzl4U8dyYiBpTziNz +hiUfq733PRYjcd9BQtcKcN4LdmQvjUHnnQ73TysCAwEAATANBgkqhkiG9w0BAQsF +AAOBgQAUFICtbuqXgL4HBRAg7yGbwokoH8Ar1QKZGe+F2WTR8vaDLOYUL7VsltLE +EJIGrcfs31nItHOBcLJuflrS8y0CQqes5puRw33LL2usSvO8z2q7JhCx+DSBi6yN +RbhcrGOllIdjsrbmd/zAMBVTUyxSisq3Nmk1cZayDvKg+GSAEA== +-----END CERTIFICATE----- diff --git a/nginx_conf/ssl/server.csr b/nginx_conf/ssl/server.csr new file mode 100644 index 000000000..80b307a91 --- /dev/null +++ b/nginx_conf/ssl/server.csr @@ -0,0 +1,11 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBhDCB7gIBADBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEh +MB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIGfMA0GCSqGSIb3DQEB +AQUAA4GNADCBiQKBgQDNloThcTVDIU1uscEdGFIDndqcCwnmYF4bs6bpJ1BxkBhq +GUNYRu12hjiHLSA80ZwhNxZ4T5YD4+81Gvs9zMoSGd4jJRSBX6evPTXR8zkmcQI/ +EtN7WgXOXhTx3JiIGlPOI3OGJR+rvfc9FiNx30FC1wpw3gt2ZC+NQeedDvdPKwID +AQABoAAwDQYJKoZIhvcNAQELBQADgYEAmCX60kspIw1Zfb79AQOarFW5Q2K2h5Vx +/cRbDyHlKtbmG77EtICccULyqf76B1gNRw5Zq3lSotSUcLzsWcdesXCFDC7k87Qf +mpQKPj6GdTYJvdWf8aDwt32tAqWuBIRoAbdx5WbFPPWVfDcm7zDJefBrhNUDH0Qd +vcnxjvPMmOM= +-----END CERTIFICATE REQUEST----- diff --git a/nginx_conf/ssl/server.key b/nginx_conf/ssl/server.key new file mode 100644 index 000000000..23a513ce1 --- /dev/null +++ b/nginx_conf/ssl/server.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQDNloThcTVDIU1uscEdGFIDndqcCwnmYF4bs6bpJ1BxkBhqGUNY +Ru12hjiHLSA80ZwhNxZ4T5YD4+81Gvs9zMoSGd4jJRSBX6evPTXR8zkmcQI/EtN7 +WgXOXhTx3JiIGlPOI3OGJR+rvfc9FiNx30FC1wpw3gt2ZC+NQeedDvdPKwIDAQAB +AoGBAIwGcfkO30UawJ+daDeF4g5ejI/toM+NYWuiwBNbWJoQl+Bj1o+gt4obvxKq +tKNX7OxelepZ4oZB0CIuf2LHQfU6cVGdu//or7nfS2FLBYStopZyL6KorZbkqsj1 +ikQN4GosJQqaYkexnwjItMFaHaRRX6YnIXp42Jl1glitO3+5AkEA+thn/vwFo24I +fC+7ORpmLi+BVAkTuhMm+C6TIV6s64B+A5oQ82OBCYK9YCOWmS6JHHFDrxJla+3M +2U9KXky63wJBANHQCFCirfuT6esSjbqpCeqtmZG5LWHtL12V9DF7yjHPjmHL9uRu +e9W+Uz33IJbqd82gtZ/ARfpYEjD0JEieQTUCQFo872xzDTQ1qSfDo/5u2MNUo5mv +ikEuEp7FYnhmrp4poyt4iRCFgy4Ask+bfdmtO/XXaRnZ7FJfQYoLVB2ITNECQQCN +gOiauZztl4yj5heAVJFDnWF9To61BOp1C7VtyjdL8NfuTUluNrV+KqapnAp2vhue +q0zTOTH47X0XVxFBiLohAkBuQzPey5I3Ui8inE4sDt/fqX8r/GMhBTxIb9KlV/H6 +jKZNs/83n5/ohaX36er8svW9PB4pcqENZ+kBpvDtKVwS +-----END RSA PRIVATE KEY----- diff --git a/package.json b/package.json index c0d93b5e0..e62f951e2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,9 @@ "main": "src/index.js", "scripts": { "test": "./node_modules/.bin/mocha --recursive test/unitary", - "integration-test": "./node_modules/.bin/mocha --recursive test/integration", + "unit-test": "./node_modules/.bin/mocha --recursive test/unitary", + "int-test": "./node_modules/.bin/mocha --recursive test/integration", + "all-test": "./node_modules/.bin/mocha --recursive test", "coverage": "./node_modules/.bin/istanbul cover _mocha -- -R spec" }, "repository": { @@ -18,16 +20,16 @@ "url": "https://github.com/clems4ever/two-factor-auth-server/issues" }, "dependencies": { + "authdog": "^0.1.1", "bluebird": "^3.4.7", "body-parser": "^1.15.2", - "cookie-parser": "^1.4.3", "ejs": "^2.5.5", "express": "^4.14.0", - "jsonwebtoken": "^7.2.1", + "express-session": "^1.14.2", "ldapjs": "^1.0.1", "object-path": "^0.11.3", - "q": "^1.4.1", - "speakeasy": "^2.0.0" + "speakeasy": "^2.0.0", + "winston": "^2.3.1" }, "devDependencies": { "mocha": "^3.2.0", diff --git a/src/index.js b/src/index.js index 1616cde7e..6e164ccf3 100644 --- a/src/index.js +++ b/src/index.js @@ -2,14 +2,15 @@ var server = require('./lib/server'); var ldap = require('ldapjs'); +var u2f = require('authdog'); 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, - jwt_secret: process.env.JWT_SECRET, - jwt_expiration_time: process.env.JWT_EXPIRATION_TIME || '1h' + session_secret: process.env.SESSION_SECRET, + session_max_age: process.env.SESSION_MAX_AGE || 3600000 // in ms } var ldap_client = ldap.createClient({ @@ -17,4 +18,4 @@ var ldap_client = ldap.createClient({ reconnect: true }); -server.run(config, ldap_client); +server.run(config, ldap_client, u2f); diff --git a/src/lib/authentication.js b/src/lib/authentication.js deleted file mode 100644 index 74993038d..000000000 --- a/src/lib/authentication.js +++ /dev/null @@ -1,19 +0,0 @@ - -module.exports = { - verify: verify_authentication -} - -var objectPath = require('object-path'); -var utils = require('./utils'); - -function verify_authentication(req, res) { - console.log('Verify authentication'); - - if(!objectPath.has(req, 'cookies.access_token')) { - return utils.reject('No access token provided'); - } - - var jsonWebToken = req.cookies['access_token']; - return req.app.get('jwt engine').verify(jsonWebToken); -} - diff --git a/src/lib/jwt.js b/src/lib/jwt.js deleted file mode 100644 index 7cc00d001..000000000 --- a/src/lib/jwt.js +++ /dev/null @@ -1,32 +0,0 @@ - -module.exports = Jwt; - -var jwt = require('jsonwebtoken'); -var utils = require('./utils'); -var Promise = require('bluebird'); - -function Jwt(secret) { - this._secret = secret; -} - -Jwt.prototype.sign = function(data, expiration_time) { - var that = this; - return new Promise(function(resolve, reject) { - var token = jwt.sign(data, that._secret, { expiresIn: expiration_time }) - resolve(token); - }); -} - -Jwt.prototype.verify = function(token) { - var that = this; - return new Promise(function(resolve, reject) { - try { - var decoded = jwt.verify(token, that._secret); - resolve(decoded); - } - catch(err) { - reject(err.message); - } - }); -} - diff --git a/src/lib/ldap.js b/src/lib/ldap.js index 77a157872..c2e36156b 100644 --- a/src/lib/ldap.js +++ b/src/lib/ldap.js @@ -7,7 +7,7 @@ var util = require('util'); var Promise = require('bluebird'); function validateCredentials(ldap_client, username, password, users_dn) { - var userDN = util.format("binding entry cn=%s,%s", username, users_dn); - var bind_promised = Promise.promisify(ldap_client.bind, ldap_client); + 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); } diff --git a/src/lib/replies.js b/src/lib/replies.js deleted file mode 100644 index a68d74faf..000000000 --- a/src/lib/replies.js +++ /dev/null @@ -1,27 +0,0 @@ - -module.exports = { - 'authentication_failed': authentication_failed, - 'authentication_succeeded': authentication_succeeded, - 'already_authenticated': already_authenticated -} - -function authentication_failed(res) { - console.log('Reply: authentication failed'); - res.status(401) - res.send('Authentication failed'); -} - -function authentication_succeeded(res, username, token) { - console.log('Reply: authentication succeeded'); - res.status(200); - res.set({ 'X-Remote-User': username }); - res.send(token); -} - -function already_authenticated(res, username) { - console.log('Reply: already authenticated'); - res.status(204); - res.set({ 'X-Remote-User': username }); - res.send(); -} - diff --git a/src/lib/routes.js b/src/lib/routes.js index 507726e3b..b9e46e202 100644 --- a/src/lib/routes.js +++ b/src/lib/routes.js @@ -1,39 +1,31 @@ var first_factor = require('./routes/first_factor'); +var second_factor = require('./routes/second_factor'); +var verify = require('./routes/verify'); module.exports = { - auth: serveAuth, login: serveLogin, logout: serveLogout, - first_factor: first_factor -} - -var authentication = require('./authentication'); -var replies = require('./replies'); - -function serveAuth(req, res) { - serveAuthGet(req, res); -} - -function serveAuthGet(req, res) { - authentication.verify(req, res) - .then(function(user) { - replies.already_authenticated(res, user); - }) - .catch(function(err) { - replies.authentication_failed(res); - console.error(err); - }); + verify: verify, + first_factor: first_factor, + second_factor: second_factor } function serveLogin(req, res) { + req.session.auth_session = {}; + req.session.auth_session.first_factor = false; + req.session.auth_session.second_factor = false; + res.render('login'); } function serveLogout(req, res) { var redirect_param = req.query.redirect; var redirect_url = redirect_param || '/'; - res.clearCookie('access_token'); + req.session.auth_session = { + first_factor: false, + second_factor: false + } res.redirect(redirect_url); } diff --git a/src/lib/routes/deny_not_logged.js b/src/lib/routes/deny_not_logged.js new file mode 100644 index 000000000..7aaf448bf --- /dev/null +++ b/src/lib/routes/deny_not_logged.js @@ -0,0 +1,24 @@ + +module.exports = denyNotLogged; + +var objectPath = require('object-path'); + +function replyWithUnauthorized(res) { + res.status(401); + res.send('Unauthorized access'); +} + +function denyNotLogged(next) { + return function(req, res) { + var auth_session = req.session.auth_session; + var first_factor = objectPath.has(req, 'session.auth_session.first_factor') + && req.session.auth_session.first_factor; + if(!first_factor) { + replyWithUnauthorized(res); + console.log('Access to this route is denied'); + return; + } + + next(req, res); + } +} diff --git a/src/lib/routes/first_factor.js b/src/lib/routes/first_factor.js index 4c4ca779a..4588a9749 100644 --- a/src/lib/routes/first_factor.js +++ b/src/lib/routes/first_factor.js @@ -2,14 +2,24 @@ module.exports = first_factor; var ldap = require('../ldap'); +var objectPath = require('object-path'); + +function replyWithUnauthorized(res) { + res.status(401); + res.send(); +} function first_factor(req, res) { + if(!objectPath.has(req, 'session.auth_session.second_factor')) { + replyWithUnauthorized(res); + } + var username = req.body.username; var password = req.body.password; console.log('Start authentication of user %s', username); if(!username || !password) { - replies.authentication_failed(res); + replyWithUnauthorized(res); return; } @@ -18,13 +28,14 @@ function first_factor(req, res) { 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; res.status(204); res.send(); console.log('LDAP binding successful'); }) - .error(function(err) { - res.status(401); - res.send(); + .catch(function(err) { + replyWithUnauthorized(res); console.log('LDAP binding failed:', err); }); } diff --git a/src/lib/routes/second_factor.js b/src/lib/routes/second_factor.js new file mode 100644 index 000000000..7b0933c67 --- /dev/null +++ b/src/lib/routes/second_factor.js @@ -0,0 +1,16 @@ + +var user_key_container = {}; +var denyNotLogged = require('./deny_not_logged'); +var u2f = require('./u2f')(user_key_container); // create a u2f handler bound to +// user key container + +module.exports = { + totp: denyNotLogged(require('./totp')), + u2f: { + register_request: denyNotLogged(u2f.register_request), + register: denyNotLogged(u2f.register), + sign_request: denyNotLogged(u2f.sign_request), + sign: denyNotLogged(u2f.sign), + } +} + diff --git a/src/lib/routes/totp.js b/src/lib/routes/totp.js new file mode 100644 index 000000000..2e8c8820f --- /dev/null +++ b/src/lib/routes/totp.js @@ -0,0 +1,33 @@ + +module.exports = totp; + +var totp = require('../totp'); +var objectPath = require('object-path'); + +var UNAUTHORIZED_MESSAGE = 'Unauthorized access'; + +function replyWithUnauthorized(res) { + res.status(401); + res.send(); +} + +function totp(req, res) { + if(!objectPath.has(req, 'session.auth_session.second_factor')) { + replyWithUnauthorized(res); + } + var token = req.body.token; + + var totp_engine = req.app.get('totp engine'); + var config = req.app.get('config'); + + totp.validate(totp_engine, token, config.totp_secret) + .then(function() { + req.session.auth_session.second_factor = true; + res.status(204); + res.send(); + }) + .catch(function(err) { + console.error(err); + replyWithUnauthorized(res); + }); +} diff --git a/src/lib/routes/u2f.js b/src/lib/routes/u2f.js new file mode 100644 index 000000000..941f73065 --- /dev/null +++ b/src/lib/routes/u2f.js @@ -0,0 +1,144 @@ + +module.exports = function(user_key_container) { + return { + register_request: register_request, + register: register(user_key_container), + sign_request: sign_request(user_key_container), + sign: sign(user_key_container), + } +} + +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 register_request(req, res) { + var u2f = req.app.get('u2f'); + var logger = req.app.get('logger'); + var app_id = util.format('https://%s', req.headers.host); + + logger.debug('U2F register_request: headers=%s', JSON.stringify(req.headers)); + logger.info('U2F register_request: Starting registration'); + u2f.startRegistration(app_id, []) + .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(user_key_container) { + return function(req, res) { + if(!objectPath.has(req, 'session.auth_session.register_request')) { + replyWithUnauthorized(res); + return; + } + + var u2f = req.app.get('u2f'); + var registrationRequest = req.session.auth_session.register_request; + var logger = req.app.get('logger'); + + logger.info('U2F register: Finishing registration'); + logger.debug('U2F register: register_request=%s', JSON.stringify(registrationRequest)); + logger.debug('U2F register: body=%s', JSON.stringify(req.body)); + + u2f.finishRegistration(registrationRequest, req.body) + .then(function(registrationStatus) { + logger.info('U2F register: Store registration and reply'); + var meta = { + keyHandle: registrationStatus.keyHandle, + publicKey: registrationStatus.publicKey, + certificate: registrationStatus.certificate + } + user_key_container[req.session.auth_session.userid] = meta; + res.status(204); + res.send(); + }, function(err) { + logger.error('U2F register: %s', err); + replyWithInternalError(res, 'Unable to complete the registration'); + }); + } +} + +function userKeyExists(req, user_key_container) { + return req.session.auth_session.userid in user_key_container; +} + + + +function sign_request(user_key_container) { + return function(req, res) { + if(!userKeyExists(req, user_key_container)) { + replyWithMissingRegistration(res); + return; + } + + var logger = req.app.get('logger'); + var u2f = req.app.get('u2f'); + var key = user_key_container[req.session.auth_session.userid]; + var app_id = util.format('https://%s', req.headers.host); + + logger.info('U2F sign_request: Start authentication'); + u2f.startAuthentication(app_id, [key]) + .then(function(authRequest) { + logger.info('U2F sign_request: Store authentication request and reply'); + req.session.auth_session.sign_request = authRequest; + res.status(200); + res.json(authRequest); + }, function(err) { + logger.info('U2F sign_request: %s', err); + replyWithUnauthorized(res); + }); + } +} + +function sign(user_key_container) { + return function(req, res) { + if(!userKeyExists(req, user_key_container)) { + replyWithMissingRegistration(res); + return; + } + + if(!objectPath.has(req, 'session.auth_session.sign_request')) { + replyWithUnauthorized(res); + return; + } + + var logger = req.app.get('logger'); + var u2f = req.app.get('u2f'); + var authRequest = req.session.auth_session.sign_request; + var key = user_key_container[req.session.auth_session.userid]; + + logger.info('U2F sign: Finish authentication'); + u2f.finishAuthentication(authRequest, req.body, [key]) + .then(function(authenticationStatus) { + logger.info('U2F sign: Authentication successful'); + req.session.auth_session.second_factor = true; + res.status(204); + res.send(); + }, function(err) { + logger.error('U2F sign: %s', err); + res.status(401); + res.send(); + }); + } +} diff --git a/src/lib/routes/verify.js b/src/lib/routes/verify.js new file mode 100644 index 000000000..68b8e5aab --- /dev/null +++ b/src/lib/routes/verify.js @@ -0,0 +1,37 @@ + +module.exports = verify; + +var objectPath = require('object-path'); +var Promise = require('bluebird'); + +function verify_filter(req, res) { + if(!objectPath.has(req, 'session.auth_session')) + return Promise.reject('No auth_session variable'); + + if(!objectPath.has(req, 'session.auth_session.first_factor')) + return Promise.reject('No first factor variable'); + + if(!objectPath.has(req, 'session.auth_session.second_factor')) + return Promise.reject('No second factor variable'); + + if(!req.session.auth_session.first_factor || + !req.session.auth_session.second_factor) + return Promise.reject('First or second factor not validated'); + + return Promise.resolve(); +} + +function verify(req, res) { + console.log('Verify authentication'); + + verify_filter(req, res) + .then(function() { + res.status(204); + res.send(); + }) + .catch(function(err) { + res.status(401); + res.send(); + }); +} + diff --git a/src/lib/server.js b/src/lib/server.js index 526297367..750c625fa 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -4,39 +4,60 @@ module.exports = { } var routes = require('./routes'); -var Jwt = require('./jwt'); var express = require('express'); var bodyParser = require('body-parser'); -var cookieParser = require('cookie-parser'); var speakeasy = require('speakeasy'); var path = require('path'); +var session = require('express-session'); +var winston = require('winston'); -function run(config, ldap_client) { +function run(config, ldap_client, u2f, fn) { var view_directory = path.resolve(__dirname, '../views'); var public_html_directory = path.resolve(__dirname, '../public_html'); var app = express(); - app.use(cookieParser()); app.use(express.static(public_html_directory)); app.use(bodyParser.urlencoded({ extended: false })); + app.use(bodyParser.json()); + app.set('trust proxy', 1); // trust first proxy + + app.use(session({ + secret: config.session_secret, + resave: false, + saveUninitialized: true, + cookie: { + secure: false, + maxAge: config.session_max_age + }, + })); app.set('views', view_directory); app.set('view engine', 'ejs'); - app.set('jwt engine', new Jwt(config.jwt_secret)); + winston.level = 'debug'; + + app.set('logger', winston); app.set('ldap client', ldap_client); app.set('totp engine', speakeasy); + app.set('u2f', u2f); app.set('config', config); app.get ('/login', routes.login); app.get ('/logout', routes.logout); - app.get ('/_auth', routes.auth); + app.get ('/_verify', routes.verify); - app.post ('/_auth/1stfactor', routes.first_factor); + app.post ('/_auth/1stfactor', routes.first_factor); + app.post ('/_auth/2ndfactor/totp', routes.second_factor.totp); + + app.get ('/_auth/2ndfactor/u2f/register_request', routes.second_factor.u2f.register_request); + app.post ('/_auth/2ndfactor/u2f/register', routes.second_factor.u2f.register); + app.get ('/_auth/2ndfactor/u2f/sign_request', routes.second_factor.u2f.sign_request); + app.post ('/_auth/2ndfactor/u2f/sign', routes.second_factor.u2f.sign); - app.listen(config.port, function(err) { + return app.listen(config.port, function(err) { console.log('Listening on %d...', config.port); + if(fn) fn(); }); } diff --git a/src/lib/totp.js b/src/lib/totp.js index a68c12350..7d3c194e0 100644 --- a/src/lib/totp.js +++ b/src/lib/totp.js @@ -3,20 +3,20 @@ module.exports = { 'validate': validate } -var Q = require('q'); +var Promise = require('bluebird'); function validate(totp_engine, token, totp_secret) { - var defer = Q.defer(); - var real_token = totp_engine.totp({ - secret: totp_secret, - encoding: 'base32' - }); + return new Promise(function(resolve, reject) { + var real_token = totp_engine.totp({ + secret: totp_secret, + encoding: 'base32' + }); - if(token == real_token) { - defer.resolve(); - } - else { - defer.reject('Wrong challenge'); - } - return defer.promise; + if(token == real_token) { + resolve(); + } + else { + reject('Wrong challenge'); + } + }); } diff --git a/src/public_html/js.cookie.min.js b/src/public_html/js.cookie.min.js deleted file mode 100644 index ff0f37b1a..000000000 --- a/src/public_html/js.cookie.min.js +++ /dev/null @@ -1,2 +0,0 @@ -/*! js-cookie v2.1.3 | MIT */ -!function(a){var b=!1;if("function"==typeof define&&define.amd&&(define(a),b=!0),"object"==typeof exports&&(module.exports=a(),b=!0),!b){var c=window.Cookies,d=window.Cookies=a();d.noConflict=function(){return window.Cookies=c,d}}}(function(){function a(){for(var a=0,b={};a1){if(f=a({path:"/"},d.defaults,f),"number"==typeof f.expires){var h=new Date;h.setMilliseconds(h.getMilliseconds()+864e5*f.expires),f.expires=h}try{g=JSON.stringify(e),/^[\{\[]/.test(g)&&(e=g)}catch(i){}return e=c.write?c.write(e,b):encodeURIComponent(e+"").replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g,decodeURIComponent),b=encodeURIComponent(b+""),b=b.replace(/%(23|24|26|2B|5E|60|7C)/g,decodeURIComponent),b=b.replace(/[\(\)]/g,escape),document.cookie=b+"="+e+(f.expires?"; expires="+f.expires.toUTCString():"")+(f.path?"; path="+f.path:"")+(f.domain?"; domain="+f.domain:"")+(f.secure?"; secure":"")}b||(g={});for(var j=document.cookie?document.cookie.split("; "):[],k=/(%[0-9A-Z]{2})+/g,l=0;l\n
\n
\n',css:"."+r+"-corner {\n position: fixed;\n margin: 5px;\n z-index: 1050;\n}\n\n."+r+"-corner ."+r+"-wrapper,\n."+r+"-corner ."+r+"-container {\n position: relative;\n display: block;\n height: inherit;\n width: inherit;\n margin: 3px;\n}\n\n."+r+"-wrapper {\n z-index: 1;\n position: absolute;\n display: inline-block;\n height: 0;\n width: 0;\n}\n\n."+r+"-container {\n display: none;\n z-index: 1;\n position: absolute;\n}\n\n."+r+"-hidable {\n cursor: pointer;\n}\n\n[data-notify-text],[data-notify-html] {\n position: relative;\n}\n\n."+r+"-arrow {\n position: absolute;\n z-index: 2;\n width: 0;\n height: 0;\n}"},p={"border-radius":["-webkit-","-moz-"]},d=function(e){return c[e]},v=function(e){if(!e)throw"Missing Style name";c[e]&&delete c[e]},m=function(t,i){if(!t)throw"Missing Style name";if(!i)throw"Missing Style definition";if(!i.html)throw"Missing Style HTML";var s=c[t];s&&s.cssElem&&(window.console&&console.warn(n+": overwriting style '"+t+"'"),c[t].cssElem.remove()),i.name=t,c[t]=i;var o="";i.classes&&e.each(i.classes,function(t,n){return o+="."+r+"-"+i.name+"-"+t+" {\n",e.each(n,function(t,n){return p[t]&&e.each(p[t],function(e,r){return o+=" "+r+t+": "+n+";\n"}),o+=" "+t+": "+n+";\n"}),o+="}\n"}),i.css&&(o+="/* styles for "+i.name+" */\n"+i.css),o&&(i.cssElem=g(o),i.cssElem.attr("id","notify-"+i.name));var u={},a=e(i.html);y("html",a,u),y("text",a,u),i.fields=u},g=function(t){var n,r,i;r=x("style"),r.attr("type","text/css"),e("head").append(r);try{r.html(t)}catch(s){r[0].styleSheet.cssText=t}return r},y=function(t,n,r){var s;return t!=="html"&&(t="text"),s="data-notify-"+t,b(n,"["+s+"]").each(function(){var n;n=e(this).attr(s),n||(n=i),r[n]=t})},b=function(e,t){return e.is(t)?e:e.find(t)},w={clickToHide:!0,autoHide:!0,autoHideDelay:5e3,arrowShow:!0,arrowSize:5,breakNewLines:!0,elementPosition:"bottom",globalPosition:"top right",style:"bootstrap",className:"error",showAnimation:"slideDown",showDuration:400,hideAnimation:"slideUp",hideDuration:200,gap:5},E=function(t,n){var r;return r=function(){},r.prototype=t,e.extend(!0,new r,n)},S=function(t){return e.extend(w,t)},x=function(t){return e("<"+t+">")},T={},N=function(t){var n;return t.is("[type=radio]")&&(n=t.parents("form:first").find("[type=radio]").filter(function(n,r){return e(r).attr("name")===t.attr("name")}),t=n.first()),t},C=function(e,t,n){var r,i;if(typeof n=="string")n=parseInt(n,10);else if(typeof n!="number")return;if(isNaN(n))return;return r=s[f[t.charAt(0)]],i=t,e[r]!==undefined&&(t=s[r.charAt(0)],n=-n),e[t]===undefined?e[t]=n:e[t]+=n,null},k=function(e,t,n){if(e==="l"||e==="t")return 0;if(e==="c"||e==="m")return n/2-t/2;if(e==="r"||e==="b")return n-t;throw"Invalid alignment"},L=function(e){return L.e=L.e||x("div"),L.e.text(e).html()};A.prototype.loadHTML=function(){var t;t=this.getStyle(),this.userContainer=e(t.html),this.userFields=t.fields},A.prototype.show=function(e,t){var n,r,i,s,o;r=function(n){return function(){!e&&!n.elem&&n.destroy();if(t)return t()}}(this),o=this.container.parent().parents(":hidden").length>0,i=this.container.add(this.arrow),n=[];if(o&&e)s="show";else if(o&&!e)s="hide";else if(!o&&e)s=this.options.showAnimation,n.push(this.options.showDuration);else{if(!!o||!!e)return r();s=this.options.hideAnimation,n.push(this.options.hideDuration)}return n.push(r),i[s].apply(i,n)},A.prototype.setGlobalPosition=function(){var t=this.getPosition(),n=t[0],i=t[1],o=s[n],u=s[i],a=n+"|"+i,f=T[a];if(!f||!document.body.contains(f[0])){f=T[a]=x("div");var l={};l[o]=0,u==="middle"?l.top="45%":u==="center"?l.left="45%":l[u]=0,f.css(l).addClass(r+"-corner"),e("body").append(f)}return f.prepend(this.wrapper)},A.prototype.setElementPosition=function(){var n,r,i,l,c,h,p,d,v,m,g,y,b,w,E,S,x,T,N,L,A,O,M,_,D,P,H,B,j;H=this.getPosition(),_=H[0],O=H[1],M=H[2],g=this.elem.position(),d=this.elem.outerHeight(),y=this.elem.outerWidth(),v=this.elem.innerHeight(),m=this.elem.innerWidth(),j=this.wrapper.position(),c=this.container.height(),h=this.container.width(),T=s[_],L=f[_],A=s[L],p={},p[A]=_==="b"?d:_==="r"?y:0,C(p,"top",g.top-j.top),C(p,"left",g.left-j.left),B=["top","left"];for(w=0,S=B.length;w=0&&C(r,s[O],i*2)}t.call(u,_)>=0?(C(p,"left",k(O,h,y)),r&&C(r,"left",k(O,i,m))):t.call(o,_)>=0&&(C(p,"top",k(O,c,d)),r&&C(r,"top",k(O,i,v))),this.container.is(":visible")&&(p.display="block"),this.container.removeAttr("style").css(p);if(r)return this.arrow.removeAttr("style").css(r)},A.prototype.getPosition=function(){var e,n,r,i,s,f,c,h;h=this.options.position||(this.elem?this.options.elementPosition:this.options.globalPosition),e=l(h),e.length===0&&(e[0]="b");if(n=e[0],t.call(a,n)<0)throw"Must be one of ["+a+"]";if(e.length===1||(r=e[0],t.call(u,r)>=0)&&(i=e[1],t.call(o,i)<0)||(s=e[0],t.call(o,s)>=0)&&(f=e[1],t.call(u,f)<0))e[1]=(c=e[0],t.call(o,c)>=0)?"m":"l";return e.length===2&&(e[2]=e[1]),e},A.prototype.getStyle=function(e){var t;e||(e=this.options.style),e||(e="default"),t=c[e];if(!t)throw"Missing style: "+e;return t},A.prototype.updateClasses=function(){var t,n;return t=["base"],e.isArray(this.options.className)?t=t.concat(this.options.className):this.options.className&&t.push(this.options.className),n=this.getStyle(),t=e.map(t,function(e){return r+"-"+n.name+"-"+e}).join(" "),this.userContainer.attr("class",t)},A.prototype.run=function(t,n){var r,s,o,u,a;e.isPlainObject(n)?e.extend(this.options,n):e.type(n)==="string"&&(this.options.className=n);if(this.container&&!t){this.show(!1);return}if(!this.container&&!t)return;s={},e.isPlainObject(t)?s=t:s[i]=t;for(o in s){r=s[o],u=this.userFields[o];if(!u)continue;u==="text"&&(r=L(r),this.options.breakNewLines&&(r=r.replace(/\n/g,"
"))),a=o===i?"":"="+o,b(this.userContainer,"[data-notify-"+u+a+"]").html(r)}this.updateClasses(),this.elem?this.setElementPosition():this.setGlobalPosition(),this.show(!0),this.options.autoHide&&(clearTimeout(this.autohideTimer),this.autohideTimer=setTimeout(this.show.bind(this,!1),this.options.autoHideDelay))},A.prototype.destroy=function(){this.wrapper.data(r,null),this.wrapper.remove()},e[n]=function(t,r,i){return t&&t.nodeName||t.jquery?e(t)[n](r,i):(i=r,r=t,new A(null,r,i)),t},e.fn[n]=function(t,n){return e(this).each(function(){var i=N(e(this)).data(r);i&&i.destroy();var s=new A(e(this),t,n)}),this},e.extend(e[n],{defaults:S,addStyle:m,removeStyle:v,pluginOptions:w,getStyle:d,insertCSS:g}),m("bootstrap",{html:"
\n\n
",classes:{base:{"font-weight":"bold",padding:"8px 15px 8px 14px","text-shadow":"0 1px 0 rgba(255, 255, 255, 0.5)","background-color":"#fcf8e3",border:"1px solid #fbeed5","border-radius":"4px","white-space":"nowrap","padding-left":"25px","background-repeat":"no-repeat","background-position":"3px 7px"},error:{color:"#B94A48","background-color":"#F2DEDE","border-color":"#EED3D7","background-image":"url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAtRJREFUeNqkVc1u00AQHq+dOD+0poIQfkIjalW0SEGqRMuRnHos3DjwAH0ArlyQeANOOSMeAA5VjyBxKBQhgSpVUKKQNGloFdw4cWw2jtfMOna6JOUArDTazXi/b3dm55socPqQhFka++aHBsI8GsopRJERNFlY88FCEk9Yiwf8RhgRyaHFQpPHCDmZG5oX2ui2yilkcTT1AcDsbYC1NMAyOi7zTX2Agx7A9luAl88BauiiQ/cJaZQfIpAlngDcvZZMrl8vFPK5+XktrWlx3/ehZ5r9+t6e+WVnp1pxnNIjgBe4/6dAysQc8dsmHwPcW9C0h3fW1hans1ltwJhy0GxK7XZbUlMp5Ww2eyan6+ft/f2FAqXGK4CvQk5HueFz7D6GOZtIrK+srupdx1GRBBqNBtzc2AiMr7nPplRdKhb1q6q6zjFhrklEFOUutoQ50xcX86ZlqaZpQrfbBdu2R6/G19zX6XSgh6RX5ubyHCM8nqSID6ICrGiZjGYYxojEsiw4PDwMSL5VKsC8Yf4VRYFzMzMaxwjlJSlCyAQ9l0CW44PBADzXhe7xMdi9HtTrdYjFYkDQL0cn4Xdq2/EAE+InCnvADTf2eah4Sx9vExQjkqXT6aAERICMewd/UAp/IeYANM2joxt+q5VI+ieq2i0Wg3l6DNzHwTERPgo1ko7XBXj3vdlsT2F+UuhIhYkp7u7CarkcrFOCtR3H5JiwbAIeImjT/YQKKBtGjRFCU5IUgFRe7fF4cCNVIPMYo3VKqxwjyNAXNepuopyqnld602qVsfRpEkkz+GFL1wPj6ySXBpJtWVa5xlhpcyhBNwpZHmtX8AGgfIExo0ZpzkWVTBGiXCSEaHh62/PoR0p/vHaczxXGnj4bSo+G78lELU80h1uogBwWLf5YlsPmgDEd4M236xjm+8nm4IuE/9u+/PH2JXZfbwz4zw1WbO+SQPpXfwG/BBgAhCNZiSb/pOQAAAAASUVORK5CYII=)"},success:{color:"#468847","background-color":"#DFF0D8","border-color":"#D6E9C6","background-image":"url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAutJREFUeNq0lctPE0Ecx38zu/RFS1EryqtgJFA08YCiMZIAQQ4eRG8eDGdPJiYeTIwHTfwPiAcvXIwXLwoXPaDxkWgQ6islKlJLSQWLUraPLTv7Gme32zoF9KSTfLO7v53vZ3d/M7/fIth+IO6INt2jjoA7bjHCJoAlzCRw59YwHYjBnfMPqAKWQYKjGkfCJqAF0xwZjipQtA3MxeSG87VhOOYegVrUCy7UZM9S6TLIdAamySTclZdYhFhRHloGYg7mgZv1Zzztvgud7V1tbQ2twYA34LJmF4p5dXF1KTufnE+SxeJtuCZNsLDCQU0+RyKTF27Unw101l8e6hns3u0PBalORVVVkcaEKBJDgV3+cGM4tKKmI+ohlIGnygKX00rSBfszz/n2uXv81wd6+rt1orsZCHRdr1Imk2F2Kob3hutSxW8thsd8AXNaln9D7CTfA6O+0UgkMuwVvEFFUbbAcrkcTA8+AtOk8E6KiQiDmMFSDqZItAzEVQviRkdDdaFgPp8HSZKAEAL5Qh7Sq2lIJBJwv2scUqkUnKoZgNhcDKhKg5aH+1IkcouCAdFGAQsuWZYhOjwFHQ96oagWgRoUov1T9kRBEODAwxM2QtEUl+Wp+Ln9VRo6BcMw4ErHRYjH4/B26AlQoQQTRdHWwcd9AH57+UAXddvDD37DmrBBV34WfqiXPl61g+vr6xA9zsGeM9gOdsNXkgpEtTwVvwOklXLKm6+/p5ezwk4B+j6droBs2CsGa/gNs6RIxazl4Tc25mpTgw/apPR1LYlNRFAzgsOxkyXYLIM1V8NMwyAkJSctD1eGVKiq5wWjSPdjmeTkiKvVW4f2YPHWl3GAVq6ymcyCTgovM3FzyRiDe2TaKcEKsLpJvNHjZgPNqEtyi6mZIm4SRFyLMUsONSSdkPeFtY1n0mczoY3BHTLhwPRy9/lzcziCw9ACI+yql0VLzcGAZbYSM5CCSZg1/9oc/nn7+i8N9p/8An4JMADxhH+xHfuiKwAAAABJRU5ErkJggg==)"},info:{color:"#3A87AD","background-color":"#D9EDF7","border-color":"#BCE8F1","background-image":"url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAYAAACNiR0NAAAABmJLR0QA/wD/AP+gvaeTAAAACXBIWXMAAAsTAAALEwEAmpwYAAAAB3RJTUUH3QYFAhkSsdes/QAAA8dJREFUOMvVlGtMW2UYx//POaWHXg6lLaW0ypAtw1UCgbniNOLcVOLmAjHZolOYlxmTGXVZdAnRfXQm+7SoU4mXaOaiZsEpC9FkiQs6Z6bdCnNYruM6KNBw6YWewzl9z+sHImEWv+vz7XmT95f/+3/+7wP814v+efDOV3/SoX3lHAA+6ODeUFfMfjOWMADgdk+eEKz0pF7aQdMAcOKLLjrcVMVX3xdWN29/GhYP7SvnP0cWfS8caSkfHZsPE9Fgnt02JNutQ0QYHB2dDz9/pKX8QjjuO9xUxd/66HdxTeCHZ3rojQObGQBcuNjfplkD3b19Y/6MrimSaKgSMmpGU5WevmE/swa6Oy73tQHA0Rdr2Mmv/6A1n9w9suQ7097Z9lM4FlTgTDrzZTu4StXVfpiI48rVcUDM5cmEksrFnHxfpTtU/3BFQzCQF/2bYVoNbH7zmItbSoMj40JSzmMyX5qDvriA7QdrIIpA+3cdsMpu0nXI8cV0MtKXCPZev+gCEM1S2NHPvWfP/hL+7FSr3+0p5RBEyhEN5JCKYr8XnASMT0xBNyzQGQeI8fjsGD39RMPk7se2bd5ZtTyoFYXftF6y37gx7NeUtJJOTFlAHDZLDuILU3j3+H5oOrD3yWbIztugaAzgnBKJuBLpGfQrS8wO4FZgV+c1IxaLgWVU0tMLEETCos4xMzEIv9cJXQcyagIwigDGwJgOAtHAwAhisQUjy0ORGERiELgG4iakkzo4MYAxcM5hAMi1WWG1yYCJIcMUaBkVRLdGeSU2995TLWzcUAzONJ7J6FBVBYIggMzmFbvdBV44Corg8vjhzC+EJEl8U1kJtgYrhCzgc/vvTwXKSib1paRFVRVORDAJAsw5FuTaJEhWM2SHB3mOAlhkNxwuLzeJsGwqWzf5TFNdKgtY5qHp6ZFf67Y/sAVadCaVY5YACDDb3Oi4NIjLnWMw2QthCBIsVhsUTU9tvXsjeq9+X1d75/KEs4LNOfcdf/+HthMnvwxOD0wmHaXr7ZItn2wuH2SnBzbZAbPJwpPx+VQuzcm7dgRCB57a1uBzUDRL4bfnI0RE0eaXd9W89mpjqHZnUI5Hh2l2dkZZUhOqpi2qSmpOmZ64Tuu9qlz/SEXo6MEHa3wOip46F1n7633eekV8ds8Wxjn37Wl63VVa+ej5oeEZ/82ZBETJjpJ1Rbij2D3Z/1trXUvLsblCK0XfOx0SX2kMsn9dX+d+7Kf6h8o4AIykuffjT8L20LU+w4AZd5VvEPY+XpWqLV327HR7DzXuDnD8r+ovkBehJ8i+y8YAAAAASUVORK5CYII=)"},warn:{color:"#C09853","background-color":"#FCF8E3","border-color":"#FBEED5","background-image":"url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAUCAMAAAC6V+0/AAABJlBMVEXr6eb/2oD/wi7/xjr/0mP/ykf/tQD/vBj/3o7/uQ//vyL/twebhgD/4pzX1K3z8e349vK6tHCilCWbiQymn0jGworr6dXQza3HxcKkn1vWvV/5uRfk4dXZ1bD18+/52YebiAmyr5S9mhCzrWq5t6ufjRH54aLs0oS+qD751XqPhAybhwXsujG3sm+Zk0PTwG6Shg+PhhObhwOPgQL4zV2nlyrf27uLfgCPhRHu7OmLgAafkyiWkD3l49ibiAfTs0C+lgCniwD4sgDJxqOilzDWowWFfAH08uebig6qpFHBvH/aw26FfQTQzsvy8OyEfz20r3jAvaKbhgG9q0nc2LbZxXanoUu/u5WSggCtp1anpJKdmFz/zlX/1nGJiYmuq5Dx7+sAAADoPUZSAAAAAXRSTlMAQObYZgAAAAFiS0dEAIgFHUgAAAAJcEhZcwAACxMAAAsTAQCanBgAAAAHdElNRQfdBgUBGhh4aah5AAAAlklEQVQY02NgoBIIE8EUcwn1FkIXM1Tj5dDUQhPU502Mi7XXQxGz5uVIjGOJUUUW81HnYEyMi2HVcUOICQZzMMYmxrEyMylJwgUt5BljWRLjmJm4pI1hYp5SQLGYxDgmLnZOVxuooClIDKgXKMbN5ggV1ACLJcaBxNgcoiGCBiZwdWxOETBDrTyEFey0jYJ4eHjMGWgEAIpRFRCUt08qAAAAAElFTkSuQmCC)"}}}),e(function(){g(h.css).attr("id","core-notify"),e(document).on("click","."+r+"-hidable",function(t){e(this).trigger("notify-hide")}),e(document).on("notify-hide","."+r+"-wrapper",function(t){var n=e(this).data(r);n&&n.show(!1)})})}) \ No newline at end of file diff --git a/src/public_html/u2f-api.js b/src/public_html/u2f-api.js new file mode 100644 index 000000000..1d229dbc0 --- /dev/null +++ b/src/public_html/u2f-api.js @@ -0,0 +1,748 @@ +//Copyright 2014-2015 Google Inc. All rights reserved. + +//Use of this source code is governed by a BSD-style +//license that can be found in the LICENSE file or at +//https://developers.google.com/open-source/licenses/bsd + +/** + * @fileoverview The U2F api. + */ +'use strict'; + + +/** + * Namespace for the U2F api. + * @type {Object} + */ +var u2f = u2f || {}; + +/** + * FIDO U2F Javascript API Version + * @number + */ +var js_api_version; + +/** + * The U2F extension id + * @const {string} + */ +// The Chrome packaged app extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the package Chrome app and does not require installing the U2F Chrome extension. + u2f.EXTENSION_ID = 'kmendfapggjehodndflmmgagdbamhnfd'; +// The U2F Chrome extension ID. +// Uncomment this if you want to deploy a server instance that uses +// the U2F Chrome extension to authenticate. +// u2f.EXTENSION_ID = 'pfboblefjcgdjicmnffhdgionmgcdmne'; + + +/** + * Message types for messsages to/from the extension + * @const + * @enum {string} + */ +u2f.MessageTypes = { + 'U2F_REGISTER_REQUEST': 'u2f_register_request', + 'U2F_REGISTER_RESPONSE': 'u2f_register_response', + 'U2F_SIGN_REQUEST': 'u2f_sign_request', + 'U2F_SIGN_RESPONSE': 'u2f_sign_response', + 'U2F_GET_API_VERSION_REQUEST': 'u2f_get_api_version_request', + 'U2F_GET_API_VERSION_RESPONSE': 'u2f_get_api_version_response' +}; + + +/** + * Response status codes + * @const + * @enum {number} + */ +u2f.ErrorCodes = { + 'OK': 0, + 'OTHER_ERROR': 1, + 'BAD_REQUEST': 2, + 'CONFIGURATION_UNSUPPORTED': 3, + 'DEVICE_INELIGIBLE': 4, + 'TIMEOUT': 5 +}; + + +/** + * A message for registration requests + * @typedef {{ + * type: u2f.MessageTypes, + * appId: ?string, + * timeoutSeconds: ?number, + * requestId: ?number + * }} + */ +u2f.U2fRequest; + + +/** + * A message for registration responses + * @typedef {{ + * type: u2f.MessageTypes, + * responseData: (u2f.Error | u2f.RegisterResponse | u2f.SignResponse), + * requestId: ?number + * }} + */ +u2f.U2fResponse; + + +/** + * An error object for responses + * @typedef {{ + * errorCode: u2f.ErrorCodes, + * errorMessage: ?string + * }} + */ +u2f.Error; + +/** + * Data object for a single sign request. + * @typedef {enum {BLUETOOTH_RADIO, BLUETOOTH_LOW_ENERGY, USB, NFC}} + */ +u2f.Transport; + + +/** + * Data object for a single sign request. + * @typedef {Array} + */ +u2f.Transports; + +/** + * Data object for a single sign request. + * @typedef {{ + * version: string, + * challenge: string, + * keyHandle: string, + * appId: string + * }} + */ +u2f.SignRequest; + + +/** + * Data object for a sign response. + * @typedef {{ + * keyHandle: string, + * signatureData: string, + * clientData: string + * }} + */ +u2f.SignResponse; + + +/** + * Data object for a registration request. + * @typedef {{ + * version: string, + * challenge: string + * }} + */ +u2f.RegisterRequest; + + +/** + * Data object for a registration response. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: Transports, + * appId: string + * }} + */ +u2f.RegisterResponse; + + +/** + * Data object for a registered key. + * @typedef {{ + * version: string, + * keyHandle: string, + * transports: ?Transports, + * appId: ?string + * }} + */ +u2f.RegisteredKey; + + +/** + * Data object for a get API register response. + * @typedef {{ + * js_api_version: number + * }} + */ +u2f.GetJsApiVersionResponse; + + +//Low level MessagePort API support + +/** + * Sets up a MessagePort to the U2F extension using the + * available mechanisms. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + */ +u2f.getMessagePort = function(callback) { + if (typeof chrome != 'undefined' && chrome.runtime) { + // The actual message here does not matter, but we need to get a reply + // for the callback to run. Thus, send an empty signature request + // in order to get a failure response. + var msg = { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: [] + }; + chrome.runtime.sendMessage(u2f.EXTENSION_ID, msg, function() { + if (!chrome.runtime.lastError) { + // We are on a whitelisted origin and can talk directly + // with the extension. + u2f.getChromeRuntimePort_(callback); + } else { + // chrome.runtime was available, but we couldn't message + // the extension directly, use iframe + u2f.getIframePort_(callback); + } + }); + } else if (u2f.isAndroidChrome_()) { + u2f.getAuthenticatorPort_(callback); + } else if (u2f.isIosChrome_()) { + u2f.getIosPort_(callback); + } else { + // chrome.runtime was not available at all, which is normal + // when this origin doesn't have access to any extensions. + u2f.getIframePort_(callback); + } +}; + +/** + * Detect chrome running on android based on the browser's useragent. + * @private + */ +u2f.isAndroidChrome_ = function() { + var userAgent = navigator.userAgent; + return userAgent.indexOf('Chrome') != -1 && + userAgent.indexOf('Android') != -1; +}; + +/** + * Detect chrome running on iOS based on the browser's platform. + * @private + */ +u2f.isIosChrome_ = function() { + return $.inArray(navigator.platform, ["iPhone", "iPad", "iPod"]) > -1; +}; + +/** + * Connects directly to the extension via chrome.runtime.connect. + * @param {function(u2f.WrappedChromeRuntimePort_)} callback + * @private + */ +u2f.getChromeRuntimePort_ = function(callback) { + var port = chrome.runtime.connect(u2f.EXTENSION_ID, + {'includeTlsChannelId': true}); + setTimeout(function() { + callback(new u2f.WrappedChromeRuntimePort_(port)); + }, 0); +}; + +/** + * Return a 'port' abstraction to the Authenticator app. + * @param {function(u2f.WrappedAuthenticatorPort_)} callback + * @private + */ +u2f.getAuthenticatorPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedAuthenticatorPort_()); + }, 0); +}; + +/** + * Return a 'port' abstraction to the iOS client app. + * @param {function(u2f.WrappedIosPort_)} callback + * @private + */ +u2f.getIosPort_ = function(callback) { + setTimeout(function() { + callback(new u2f.WrappedIosPort_()); + }, 0); +}; + +/** + * A wrapper for chrome.runtime.Port that is compatible with MessagePort. + * @param {Port} port + * @constructor + * @private + */ +u2f.WrappedChromeRuntimePort_ = function(port) { + this.port_ = port; +}; + +/** + * Format and return a sign request compliant with the JS API version supported by the extension. + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatSignRequest_ = + function(appId, challenge, registeredKeys, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: challenge, + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + signRequests: signRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_SIGN_REQUEST, + appId: appId, + challenge: challenge, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + +/** + * Format and return a register request compliant with the JS API version supported by the extension.. + * @param {Array} signRequests + * @param {Array} signRequests + * @param {number} timeoutSeconds + * @param {number} reqId + * @return {Object} + */ +u2f.formatRegisterRequest_ = + function(appId, registeredKeys, registerRequests, timeoutSeconds, reqId) { + if (js_api_version === undefined || js_api_version < 1.1) { + // Adapt request to the 1.0 JS API + for (var i = 0; i < registerRequests.length; i++) { + registerRequests[i].appId = appId; + } + var signRequests = []; + for (var i = 0; i < registeredKeys.length; i++) { + signRequests[i] = { + version: registeredKeys[i].version, + challenge: registerRequests[0], + keyHandle: registeredKeys[i].keyHandle, + appId: appId + }; + } + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + signRequests: signRequests, + registerRequests: registerRequests, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; + } + // JS 1.1 API + return { + type: u2f.MessageTypes.U2F_REGISTER_REQUEST, + appId: appId, + registerRequests: registerRequests, + registeredKeys: registeredKeys, + timeoutSeconds: timeoutSeconds, + requestId: reqId + }; +}; + + +/** + * Posts a message on the underlying channel. + * @param {Object} message + */ +u2f.WrappedChromeRuntimePort_.prototype.postMessage = function(message) { + this.port_.postMessage(message); +}; + + +/** + * Emulates the HTML 5 addEventListener interface. Works only for the + * onmessage event, which is hooked up to the chrome.runtime.Port.onMessage. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedChromeRuntimePort_.prototype.addEventListener = + function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message' || name == 'onmessage') { + this.port_.onMessage.addListener(function(message) { + // Emulate a minimal MessageEvent object + handler({'data': message}); + }); + } else { + console.error('WrappedChromeRuntimePort only supports onMessage'); + } +}; + +/** + * Wrap the Authenticator app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedAuthenticatorPort_ = function() { + this.requestId_ = -1; + this.requestObject_ = null; +} + +/** + * Launch the Authenticator intent. + * @param {Object} message + */ +u2f.WrappedAuthenticatorPort_.prototype.postMessage = function(message) { + var intentUrl = + u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ + + ';S.request=' + encodeURIComponent(JSON.stringify(message)) + + ';end'; + document.location = intentUrl; +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedAuthenticatorPort_.prototype.getPortType = function() { + return "WrappedAuthenticatorPort_"; +}; + + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedAuthenticatorPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name == 'message') { + var self = this; + /* Register a callback to that executes when + * chrome injects the response. */ + window.addEventListener( + 'message', self.onRequestUpdate_.bind(self, handler), false); + } else { + console.error('WrappedAuthenticatorPort only supports message'); + } +}; + +/** + * Callback invoked when a response is received from the Authenticator. + * @param function({data: Object}) callback + * @param {Object} message message Object + */ +u2f.WrappedAuthenticatorPort_.prototype.onRequestUpdate_ = + function(callback, message) { + var messageObject = JSON.parse(message.data); + var intentUrl = messageObject['intentURL']; + + var errorCode = messageObject['errorCode']; + var responseObject = null; + if (messageObject.hasOwnProperty('data')) { + responseObject = /** @type {Object} */ ( + JSON.parse(messageObject['data'])); + } + + callback({'data': responseObject}); +}; + +/** + * Base URL for intents to Authenticator. + * @const + * @private + */ +u2f.WrappedAuthenticatorPort_.INTENT_URL_BASE_ = + 'intent:#Intent;action=com.google.android.apps.authenticator.AUTHENTICATE'; + +/** + * Wrap the iOS client app with a MessagePort interface. + * @constructor + * @private + */ +u2f.WrappedIosPort_ = function() {}; + +/** + * Launch the iOS client app request + * @param {Object} message + */ +u2f.WrappedIosPort_.prototype.postMessage = function(message) { + var str = JSON.stringify(message); + var url = "u2f://auth?" + encodeURI(str); + location.replace(url); +}; + +/** + * Tells what type of port this is. + * @return {String} port type + */ +u2f.WrappedIosPort_.prototype.getPortType = function() { + return "WrappedIosPort_"; +}; + +/** + * Emulates the HTML 5 addEventListener interface. + * @param {string} eventName + * @param {function({data: Object})} handler + */ +u2f.WrappedIosPort_.prototype.addEventListener = function(eventName, handler) { + var name = eventName.toLowerCase(); + if (name !== 'message') { + console.error('WrappedIosPort only supports message'); + } +}; + +/** + * Sets up an embedded trampoline iframe, sourced from the extension. + * @param {function(MessagePort)} callback + * @private + */ +u2f.getIframePort_ = function(callback) { + // Create the iframe + var iframeOrigin = 'chrome-extension://' + u2f.EXTENSION_ID; + var iframe = document.createElement('iframe'); + iframe.src = iframeOrigin + '/u2f-comms.html'; + iframe.setAttribute('style', 'display:none'); + document.body.appendChild(iframe); + + var channel = new MessageChannel(); + var ready = function(message) { + if (message.data == 'ready') { + channel.port1.removeEventListener('message', ready); + callback(channel.port1); + } else { + console.error('First event on iframe port was not "ready"'); + } + }; + channel.port1.addEventListener('message', ready); + channel.port1.start(); + + iframe.addEventListener('load', function() { + // Deliver the port to the iframe and initialize + iframe.contentWindow.postMessage('init', iframeOrigin, [channel.port2]); + }); +}; + + +//High-level JS API + +/** + * Default extension response timeout in seconds. + * @const + */ +u2f.EXTENSION_TIMEOUT_SEC = 30; + +/** + * A singleton instance for a MessagePort to the extension. + * @type {MessagePort|u2f.WrappedChromeRuntimePort_} + * @private + */ +u2f.port_ = null; + +/** + * Callbacks waiting for a port + * @type {Array} + * @private + */ +u2f.waitingForPort_ = []; + +/** + * A counter for requestIds. + * @type {number} + * @private + */ +u2f.reqCounter_ = 0; + +/** + * A map from requestIds to client callbacks + * @type {Object.} + * @private + */ +u2f.callbackMap_ = {}; + +/** + * Creates or retrieves the MessagePort singleton to use. + * @param {function((MessagePort|u2f.WrappedChromeRuntimePort_))} callback + * @private + */ +u2f.getPortSingleton_ = function(callback) { + if (u2f.port_) { + callback(u2f.port_); + } else { + if (u2f.waitingForPort_.length == 0) { + u2f.getMessagePort(function(port) { + u2f.port_ = port; + u2f.port_.addEventListener('message', + /** @type {function(Event)} */ (u2f.responseHandler_)); + + // Careful, here be async callbacks. Maybe. + while (u2f.waitingForPort_.length) + u2f.waitingForPort_.shift()(u2f.port_); + }); + } + u2f.waitingForPort_.push(callback); + } +}; + +/** + * Handles response messages from the extension. + * @param {MessageEvent.} message + * @private + */ +u2f.responseHandler_ = function(message) { + var response = message.data; + var reqId = response['requestId']; + if (!reqId || !u2f.callbackMap_[reqId]) { + console.error('Unknown or missing requestId in response.'); + return; + } + var cb = u2f.callbackMap_[reqId]; + delete u2f.callbackMap_[reqId]; + cb(response['responseData']); +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the sign request. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sign = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual sign request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0 : response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual sign request in the supported API version. + u2f.sendSignRequest(appId, challenge, registeredKeys, callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches an array of sign requests to available U2F tokens. + * @param {string=} appId + * @param {string=} challenge + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.SignResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendSignRequest = function(appId, challenge, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatSignRequest_(appId, challenge, registeredKeys, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * If the JS API version supported by the extension is unknown, it first sends a + * message to the extension to find out the supported API version and then it sends + * the register request. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.register = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + if (js_api_version === undefined) { + // Send a message to get the extension to JS API version, then send the actual register request. + u2f.getApiVersion( + function (response) { + js_api_version = response['js_api_version'] === undefined ? 0: response['js_api_version']; + console.log("Extension JS API Version: ", js_api_version); + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + }); + } else { + // We know the JS API version. Send the actual register request in the supported API version. + u2f.sendRegisterRequest(appId, registerRequests, registeredKeys, + callback, opt_timeoutSeconds); + } +}; + +/** + * Dispatches register requests to available U2F tokens. An array of sign + * requests identifies already registered tokens. + * @param {string=} appId + * @param {Array} registerRequests + * @param {Array} registeredKeys + * @param {function((u2f.Error|u2f.RegisterResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.sendRegisterRequest = function(appId, registerRequests, registeredKeys, callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var timeoutSeconds = (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC); + var req = u2f.formatRegisterRequest_( + appId, registeredKeys, registerRequests, timeoutSeconds, reqId); + port.postMessage(req); + }); +}; + + +/** + * Dispatches a message to the extension to find out the supported + * JS API version. + * If the user is on a mobile phone and is thus using Google Authenticator instead + * of the Chrome extension, don't send the request and simply return 0. + * @param {function((u2f.Error|u2f.GetJsApiVersionResponse))} callback + * @param {number=} opt_timeoutSeconds + */ +u2f.getApiVersion = function(callback, opt_timeoutSeconds) { + u2f.getPortSingleton_(function(port) { + // If we are using Android Google Authenticator or iOS client app, + // do not fire an intent to ask which JS API version to use. + if (port.getPortType) { + var apiVersion; + switch (port.getPortType()) { + case 'WrappedIosPort_': + case 'WrappedAuthenticatorPort_': + apiVersion = 1.1; + break; + + default: + apiVersion = 0; + break; + } + callback({ 'js_api_version': apiVersion }); + return; + } + var reqId = ++u2f.reqCounter_; + u2f.callbackMap_[reqId] = callback; + var req = { + type: u2f.MessageTypes.U2F_GET_API_VERSION_REQUEST, + timeoutSeconds: (typeof opt_timeoutSeconds !== 'undefined' ? + opt_timeoutSeconds : u2f.EXTENSION_TIMEOUT_SEC), + requestId: reqId + }; + port.postMessage(req); + }); +}; diff --git a/src/views/login.ejs b/src/views/login.ejs index 928684047..5278bf210 100644 --- a/src/views/login.ejs +++ b/src/views/login.ejs @@ -4,18 +4,31 @@ -