From d7d743bdfa81557259b5dd12ffc7b43a6fc64a80 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 10 Dec 2016 01:47:58 +0100 Subject: [PATCH] First commit with tests --- .gitignore | 3 + Dockerfile | 7 ++ app/index.js | 37 ++++++++++ app/lib/authentication.js | 57 +++++++++++++++ app/lib/jwt.js | 29 ++++++++ app/lib/ldap_checker.js | 14 ++++ app/lib/replies.js | 29 ++++++++ app/lib/routes.js | 35 +++++++++ app/lib/totp_checker.js | 22 ++++++ app/lib/utils.js | 35 +++++++++ app/public_html/login.css | 58 +++++++++++++++ app/tests/authentication_test.js | 118 +++++++++++++++++++++++++++++++ app/tests/jwt_test.js | 33 +++++++++ app/tests/ldap_checker_test.js | 35 +++++++++ app/tests/replies_test.js | 52 ++++++++++++++ app/tests/res_mock.js | 24 +++++++ app/tests/totp_checker_test.js | 32 +++++++++ app/views/login.ejs | 17 +++++ docker-compose.yml | 39 ++++++++++ package.json | 36 ++++++++++ proxy/index.html | 5 ++ proxy/nginx.conf | 76 ++++++++++++++++++++ secret/index.html | 5 ++ secret/nginx.conf | 32 +++++++++ 24 files changed, 830 insertions(+) create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 app/index.js create mode 100644 app/lib/authentication.js create mode 100644 app/lib/jwt.js create mode 100644 app/lib/ldap_checker.js create mode 100644 app/lib/replies.js create mode 100644 app/lib/routes.js create mode 100644 app/lib/totp_checker.js create mode 100644 app/lib/utils.js create mode 100644 app/public_html/login.css create mode 100644 app/tests/authentication_test.js create mode 100644 app/tests/jwt_test.js create mode 100644 app/tests/ldap_checker_test.js create mode 100644 app/tests/replies_test.js create mode 100644 app/tests/res_mock.js create mode 100644 app/tests/totp_checker_test.js create mode 100644 app/views/login.ejs create mode 100644 docker-compose.yml create mode 100644 package.json create mode 100644 proxy/index.html create mode 100644 proxy/nginx.conf create mode 100644 secret/index.html create mode 100644 secret/nginx.conf diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..41195164d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ + +node_modules/ + diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 000000000..de6b44776 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,7 @@ +FROM node + +WORKDIR /usr/src + +COPY app /usr/src + +CMD ["node", "app.js"] diff --git a/app/index.js b/app/index.js new file mode 100644 index 000000000..695502f1e --- /dev/null +++ b/app/index.js @@ -0,0 +1,37 @@ + +var express = require('express'); +var bodyParser = require('body-parser'); +var cookieParser = require('cookie-parser'); +var routes = require('./lib/routes'); +var ldap = require('ldapjs'); +var speakeasy = require('speakeasy'); + +var totpSecret = process.env.SECRET; +var LDAP_URL = process.env.LDAP_URL || 'ldap://127.0.0.1:389'; +var USERS_DN = process.env.USERS_DN; +var PORT = process.env.PORT || 80 +var JWT_SECRET = 'this is the secret'; +var EXPIRATION_TIME = process.env.EXPIRATION_TIME || '1h'; + +var ldap_client = ldap.createClient({ + url: LDAP_URL +}); + + +var app = express(); +app.use(cookieParser()); +app.use(express.static(__dirname + '/public_html')); +app.use(bodyParser.urlencoded({ extended: false })); + +app.set('view engine', 'ejs'); + +app.get ('/login', routes.login); +app.post ('/login', routes.login); + +app.get ('/logout', routes.logout); +app.get ('/_auth', routes.auth); + +app.listen(PORT, function(err) { + console.log('Listening on %d...', PORT); +}); + diff --git a/app/lib/authentication.js b/app/lib/authentication.js new file mode 100644 index 000000000..3c276b3a0 --- /dev/null +++ b/app/lib/authentication.js @@ -0,0 +1,57 @@ + +module.exports = { + 'authenticate': authenticate, + 'verify_authentication': verify_authentication +} + +var objectPath = require('object-path'); +var Jwt = require('./jwt'); +var ldap_checker = require('./ldap_checker'); +var totp_checker = require('./totp_checker'); +var replies = require('./replies'); +var Q = require('q'); +var utils = require('./utils'); + + +function authenticate(req, res, args) { + var defer = Q.defer(); + var username = req.body.username; + var password = req.body.password; + var token = req.body.token; + console.log('Start authentication'); + + if(!username || !password || !token) { + replies.authentication_failed(res); + return; + } + + var totp_promise = totp_checker.validate(args.totp_interface, token, args.totp_secret); + var credentials_promise = ldap_checker.validate(args.ldap_interface, username, password, args.users_dn); + + Q.all([totp_promise, credentials_promise]) + .then(function() { + var token = args.jwt.sign({ user: username }, args.jwt_expiration_time); + res.cookie('access_token', token); + res.redirect('/'); + console.log('Authentication succeeded'); + defer.resolve(); + }) + .fail(function(err1, err2) { + res.render('login'); + console.log('Authentication failed', err1, err2); + defer.reject(); + }); + return defer.promise; +} + +function verify_authentication(req, res, args) { + 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 args.jwt.verify(jsonWebToken); +} + diff --git a/app/lib/jwt.js b/app/lib/jwt.js new file mode 100644 index 000000000..1fcba6794 --- /dev/null +++ b/app/lib/jwt.js @@ -0,0 +1,29 @@ + +module.exports = Jwt; + +var jwt = require('jsonwebtoken'); +var utils = require('./utils'); +var Q = require('q'); + +function Jwt(secret) { + var _secret; + + this._secret = secret; +} + +Jwt.prototype.sign = function(data, expiration_time) { + return jwt.sign(data, this._secret, { expiresIn: expiration_time }); +} + +Jwt.prototype.verify = function(token) { + var defer = Q.defer(); + try { + var decoded = jwt.verify(token, this._secret); + defer.resolve(decoded); + } + catch(err) { + defer.reject(err); + } + return defer.promise; +} + diff --git a/app/lib/ldap_checker.js b/app/lib/ldap_checker.js new file mode 100644 index 000000000..8f416a76a --- /dev/null +++ b/app/lib/ldap_checker.js @@ -0,0 +1,14 @@ + +module.exports = { + 'validate': validateCredentials +} + +var Q = require('q'); +var util = require('util'); +var utils = require('./utils'); + +function validateCredentials(ldap_client, username, password, users_dn) { + var userDN = util.format("cn=%s,%s", username, users_dn); + var bind_promised = utils.promisify(ldap_client.bind, ldap_client); + return bind_promised(userDN, password); +} diff --git a/app/lib/replies.js b/app/lib/replies.js new file mode 100644 index 000000000..f60199a16 --- /dev/null +++ b/app/lib/replies.js @@ -0,0 +1,29 @@ + +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 send_success(res, username, msg) { + res.status(200); + res.set({ 'X-Remote-User': username }); + res.send(msg); +} + +function authentication_succeeded(res, username) { + console.log('Reply: authentication succeeded'); + send_success(res, username, 'Authentication succeeded'); +} + +function already_authenticated(res, username) { + console.log('Reply: already authenticated'); + send_success(res, username, 'Authentication succeeded'); +} + diff --git a/app/lib/routes.js b/app/lib/routes.js new file mode 100644 index 000000000..cadcd116c --- /dev/null +++ b/app/lib/routes.js @@ -0,0 +1,35 @@ + +module.exports = { + 'auth': serveAuth, + 'login': serveLogin, + 'logout': serveLogout +} + +var authentication = require('./authentication'); +var replies = require('./replies'); + +function serveAuth(req, res) { + authentication.verify(req, res) + .then(function(user) { + replies.already_authenticated(res, user); + }) + .fail(function(err) { + replies.authentication_failed(res); + console.error(err); + }); +} + +function serveLogin(req, res) { + console.log('METHOD=%s', req.method); + if(req.method == 'POST') { + authentication.authenticate(req, res); + } + else { + res.render('login'); + } +} + +function serveLogout(req, res) { + res.clearCookie('access_token'); + res.redirect('/'); +} diff --git a/app/lib/totp_checker.js b/app/lib/totp_checker.js new file mode 100644 index 000000000..24c061d93 --- /dev/null +++ b/app/lib/totp_checker.js @@ -0,0 +1,22 @@ + +module.exports = { + 'validate': validate +} + +var Q = require('q'); + +function validate(speakeasy, token, totp_secret) { + var defer = Q.defer(); + var real_token = speakeasy.totp({ + secret: totp_secret, + encoding: 'base32' + }); + + if(token == real_token) { + defer.resolve(); + } + else { + defer.reject('Wrong challenge'); + } + return defer.promise; +} diff --git a/app/lib/utils.js b/app/lib/utils.js new file mode 100644 index 000000000..9b3845f41 --- /dev/null +++ b/app/lib/utils.js @@ -0,0 +1,35 @@ + +module.exports = { + 'promisify': promisify, + 'resolve': resolve, + 'reject': reject +} + +var Q = require('q'); + +function promisify(fn, context) { + return function() { + var defer = Q.defer(); + var args = Array.prototype.slice.call(arguments); + args.push(function(err, val) { + if (err !== null && err !== undefined) { + return defer.reject(err); + } + return defer.resolve(val); + }); + fn.apply(context || {}, args); + return defer.promise; + }; +} + +function resolve(data) { + var defer = Q.defer(); + defer.resolve(data); + return defer.promise; +} + +function reject(err) { + var defer = Q.defer(); + defer.reject(err); + return defer.promise; +} diff --git a/app/public_html/login.css b/app/public_html/login.css new file mode 100644 index 000000000..8d0a754f9 --- /dev/null +++ b/app/public_html/login.css @@ -0,0 +1,58 @@ +@import url(http://fonts.googleapis.com/css?family=Open+Sans); +.btn { display: inline-block; *display: inline; *zoom: 1; padding: 4px 10px 4px; margin-bottom: 0; font-size: 13px; line-height: 18px; color: #333333; text-align: center;text-shadow: 0 1px 1px rgba(255, 255, 255, 0.75); vertical-align: middle; background-color: #f5f5f5; background-image: -moz-linear-gradient(top, #ffffff, #e6e6e6); background-image: -ms-linear-gradient(top, #ffffff, #e6e6e6); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#ffffff), to(#e6e6e6)); background-image: -webkit-linear-gradient(top, #ffffff, #e6e6e6); background-image: -o-linear-gradient(top, #ffffff, #e6e6e6); background-image: linear-gradient(top, #ffffff, #e6e6e6); background-repeat: repeat-x; filter: progid:dximagetransform.microsoft.gradient(startColorstr=#ffffff, endColorstr=#e6e6e6, GradientType=0); border-color: #e6e6e6 #e6e6e6 #e6e6e6; border-color: rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.1) rgba(0, 0, 0, 0.25); border: 1px solid #e6e6e6; -webkit-border-radius: 4px; -moz-border-radius: 4px; border-radius: 4px; -webkit-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); -moz-box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05); cursor: pointer; *margin-left: .3em; } +.btn:hover, .btn:active, .btn.active, .btn.disabled, .btn[disabled] { background-color: #e6e6e6; } +.btn-large { padding: 9px 14px; font-size: 15px; line-height: normal; -webkit-border-radius: 5px; -moz-border-radius: 5px; border-radius: 5px; } +.btn:hover { color: #333333; text-decoration: none; background-color: #e6e6e6; background-position: 0 -15px; -webkit-transition: background-position 0.1s linear; -moz-transition: background-position 0.1s linear; -ms-transition: background-position 0.1s linear; -o-transition: background-position 0.1s linear; transition: background-position 0.1s linear; } +.btn-primary, .btn-primary:hover { text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25); color: #ffffff; } +.btn-primary.active { color: rgba(255, 255, 255, 0.75); } +.btn-primary { background-color: #4a77d4; background-image: -moz-linear-gradient(top, #6eb6de, #4a77d4); background-image: -ms-linear-gradient(top, #6eb6de, #4a77d4); background-image: -webkit-gradient(linear, 0 0, 0 100%, from(#6eb6de), to(#4a77d4)); background-image: -webkit-linear-gradient(top, #6eb6de, #4a77d4); background-image: -o-linear-gradient(top, #6eb6de, #4a77d4); background-image: linear-gradient(top, #6eb6de, #4a77d4); background-repeat: repeat-x; filter: progid:dximagetransform.microsoft.gradient(startColorstr=#6eb6de, endColorstr=#4a77d4, GradientType=0); border: 1px solid #3762bc; text-shadow: 1px 1px 1px rgba(0,0,0,0.4); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.5); } +.btn-primary:hover, .btn-primary:active, .btn-primary.active, .btn-primary.disabled, .btn-primary[disabled] { filter: none; background-color: #4a77d4; } +.btn-block { width: 100%; display:block; } + +* { -webkit-box-sizing:border-box; -moz-box-sizing:border-box; -ms-box-sizing:border-box; -o-box-sizing:border-box; box-sizing:border-box; } + +html { width: 100%; height:100%; overflow:hidden; } + +body { + width: 100%; + height:100%; + font-family: 'Open Sans', sans-serif; + background: #092756; + background: -moz-radial-gradient(0% 100%, ellipse cover, rgba(104,128,138,.4) 10%,rgba(138,114,76,0) 40%),-moz-linear-gradient(top, rgba(57,173,219,.25) 0%, rgba(42,60,87,.4) 100%), -moz-linear-gradient(-45deg, #670d10 0%, #092756 100%); + background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104,128,138,.4) 10%,rgba(138,114,76,0) 40%), -webkit-linear-gradient(top, rgba(57,173,219,.25) 0%,rgba(42,60,87,.4) 100%), -webkit-linear-gradient(-45deg, #670d10 0%,#092756 100%); + background: -o-radial-gradient(0% 100%, ellipse cover, rgba(104,128,138,.4) 10%,rgba(138,114,76,0) 40%), -o-linear-gradient(top, rgba(57,173,219,.25) 0%,rgba(42,60,87,.4) 100%), -o-linear-gradient(-45deg, #670d10 0%,#092756 100%); + background: -ms-radial-gradient(0% 100%, ellipse cover, rgba(104,128,138,.4) 10%,rgba(138,114,76,0) 40%), -ms-linear-gradient(top, rgba(57,173,219,.25) 0%,rgba(42,60,87,.4) 100%), -ms-linear-gradient(-45deg, #670d10 0%,#092756 100%); + background: -webkit-radial-gradient(0% 100%, ellipse cover, rgba(104,128,138,.4) 10%,rgba(138,114,76,0) 40%), linear-gradient(to bottom, rgba(57,173,219,.25) 0%,rgba(42,60,87,.4) 100%), linear-gradient(135deg, #670d10 0%,#092756 100%); + filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#3E1D6D', endColorstr='#092756',GradientType=1 ); +} +.login { + position: absolute; + top: 50%; + left: 50%; + margin: -150px 0 0 -150px; + width:300px; + height:300px; +} +.login h1 { color: #fff; text-shadow: 0 0 10px rgba(0,0,0,0.3); letter-spacing:1px; text-align:center; } + +input { + width: 100%; + margin-bottom: 10px; + background: rgba(0,0,0,0.3); + border: none; + outline: none; + padding: 10px; + font-size: 13px; + color: #fff; + text-shadow: 1px 1px 1px rgba(0,0,0,0.3); + border: 1px solid rgba(0,0,0,0.3); + border-radius: 4px; + box-shadow: inset 0 -5px 45px rgba(100,100,100,0.2), 0 1px 1px rgba(255,255,255,0.2); + -webkit-transition: box-shadow .5s ease; + -moz-transition: box-shadow .5s ease; + -o-transition: box-shadow .5s ease; + -ms-transition: box-shadow .5s ease; + transition: box-shadow .5s ease; +} +input:focus { box-shadow: inset 0 -5px 45px rgba(100,100,100,0.4), 0 1px 1px rgba(255,255,255,0.2); } + diff --git a/app/tests/authentication_test.js b/app/tests/authentication_test.js new file mode 100644 index 000000000..7e27b699c --- /dev/null +++ b/app/tests/authentication_test.js @@ -0,0 +1,118 @@ + +var assert = require('assert'); +var authentication = require('../lib/authentication'); +var create_res_mock = require('./res_mock'); +var sinon = require('sinon'); +var sinonPromise = require('sinon-promise'); +sinonPromise(sinon); + +var autoResolving = sinon.promise().resolves(); + +function create_req_mock(token) { + return { + body: { + username: 'username', + password: 'password', + token: token + }, + cookies: { + 'access_token': 'cookie_token' + } + } +} + +function create_mocks() { + var totp_token = 'totp_token'; + var jwt_token = 'jwt_token'; + + var res_mock = create_res_mock(); + var req_mock = create_req_mock(totp_token); + var bind_mock = sinon.mock(); + var totp_mock = sinon.mock(); + var sign_mock = sinon.mock(); + var verify_mock = sinon.promise(); + var jwt = { + sign: sign_mock, + verify: verify_mock + }; + var ldap_interface_mock = { + bind: bind_mock + }; + var totp_interface_mock = { + totp: totp_mock + }; + + bind_mock.yields(); + totp_mock.returns(totp_token); + sign_mock.returns(jwt_token); + + var args = { + totp_secret: 'totp_secret', + jwt: jwt, + jwt_expiration_time: '1h', + users_dn: 'dc=example,dc=com', + ldap_interface: ldap_interface_mock, + totp_interface: totp_interface_mock + } + + return { + req: req_mock, + res: res_mock, + args: args, + totp: totp_mock, + jwt: jwt + } +} + +describe('test jwt', function() { + describe('test authentication', function() { + it('should authenticate user successfuly', function(done) { + var jwt_token = 'jwt_token'; + var clock = sinon.useFakeTimers(); + var mocks = create_mocks(); + authentication.authenticate(mocks.req, mocks.res, mocks.args) + .then(function() { + clock.restore(); + assert(mocks.res.cookie.calledWith('access_token', jwt_token)); + assert(mocks.res.redirect.calledWith('/')); + done(); + }) + }); + + it('should fail authentication', function(done) { + var clock = sinon.useFakeTimers(); + var mocks = create_mocks(); + mocks.totp.returns('wrong token'); + authentication.authenticate(mocks.req, mocks.res, mocks.args) + .fail(function(err) { + clock.restore(); + done(); + }) + }); + }); + + + describe('test verify authentication', function() { + it('should be already authenticated', function(done) { + var mocks = create_mocks(); + var data = { user: 'username' }; + mocks.jwt.verify = sinon.promise().resolves(data); + authentication.verify_authentication(mocks.req, mocks.res, mocks.args) + .then(function(actual_data) { + assert.equal(actual_data, data); + done(); + }); + }); + + it('should not be already authenticated', function(done) { + var mocks = create_mocks(); + var data = { user: 'username' }; + mocks.jwt.verify = sinon.promise().rejects('Error with JWT token'); + return authentication.verify_authentication(mocks.req, mocks.res, mocks.args) + .fail(function() { + done(); + }); + }); + }); +}); + diff --git a/app/tests/jwt_test.js b/app/tests/jwt_test.js new file mode 100644 index 000000000..45e6b6037 --- /dev/null +++ b/app/tests/jwt_test.js @@ -0,0 +1,33 @@ + +var Jwt = require('../lib/jwt'); +var sinon = require('sinon'); +var sinonPromise = require('sinon-promise'); +sinonPromise(sinon); + +var autoResolving = sinon.promise().resolves(); + +describe('test jwt', function() { + it('should sign and verify the token', function() { + var data = {user: 'user'}; + var secret = 'secret'; + var jwt = new Jwt(secret); + var token = jwt.sign(data, '1m'); + return jwt.verify(token); + }); + + it('should verify and fail on wrong token', function() { + var jwt = new Jwt('secret'); + return jwt.verify('wrong token').fail(autoResolving); + }); + + it('should fail after expiry', function(done) { + var clock = sinon.useFakeTimers(0); + var data = {user: 'user'}; + var jwt = new Jwt('secret'); + var token = jwt.sign(data, '1m'); + clock.tick(1000 * 61); // 61 seconds + jwt.verify(token).fail(function() { done(); }); + clock.restore(); + }); +}); + diff --git a/app/tests/ldap_checker_test.js b/app/tests/ldap_checker_test.js new file mode 100644 index 000000000..7a672fe72 --- /dev/null +++ b/app/tests/ldap_checker_test.js @@ -0,0 +1,35 @@ + +var ldap_checker = require('../lib/ldap_checker'); +var sinon = require('sinon'); +var sinonPromise = require('sinon-promise'); + +sinonPromise(sinon); + +var autoResolving = sinon.promise().resolves(); + +function test_validate(bind_mock) { + var username = 'user'; + var password = 'password'; + var ldap_url = 'http://ldap'; + var users_dn = 'dc=example,dc=com'; + + var ldap_client_mock = { + bind: bind_mock + } + + return ldap_checker.validate(ldap_client_mock, username, password, ldap_url, users_dn); +} + +describe('test ldap checker', function() { + it('should bind the user if good credentials provided', function() { + var bind_mock = sinon.mock().yields(); + return test_validate(bind_mock); + }); + + it('should not bind the user if wrong credentials provided', function() { + var bind_mock = sinon.mock().yields('wrong credentials'); + var promise = test_validate(bind_mock); + return promise.fail(autoResolving); + }); +}); + diff --git a/app/tests/replies_test.js b/app/tests/replies_test.js new file mode 100644 index 000000000..4c88f3fde --- /dev/null +++ b/app/tests/replies_test.js @@ -0,0 +1,52 @@ + +var replies = require('../lib/replies'); +var assert = require('assert'); +var sinon = require('sinon'); +var sinonPromise = require('sinon-promise'); +sinonPromise(sinon); + +var autoResolving = sinon.promise().resolves(); + +function create_res_mock() { + var status_mock = sinon.mock(); + var send_mock = sinon.mock(); + var set_mock = sinon.mock(); + + return { + status: status_mock, + send: send_mock, + set: set_mock + }; +} + +describe('test jwt', function() { + it('should authenticate with success', function() { + var res_mock = create_res_mock(); + var username = 'username'; + + replies.authentication_succeeded(res_mock, username); + + assert(res_mock.status.calledWith(200)); + assert(res_mock.set.calledWith({'X-Remote-User': username })); + }); + + it('should reply successfully when already authenticated', function() { + var res_mock = create_res_mock(); + var username = 'username'; + + replies.already_authenticated(res_mock, username); + + assert(res_mock.status.calledWith(200)); + assert(res_mock.set.calledWith({'X-Remote-User': username })); + }); + + it('should reply with failed authentication', function() { + var res_mock = create_res_mock(); + var username = 'username'; + + replies.authentication_failed(res_mock, username); + + assert(res_mock.status.calledWith(401)); + }); +}); + diff --git a/app/tests/res_mock.js b/app/tests/res_mock.js new file mode 100644 index 000000000..1ac02a501 --- /dev/null +++ b/app/tests/res_mock.js @@ -0,0 +1,24 @@ + +module.exports = create_res_mock; + +var sinon = require('sinon'); +var sinonPromise = require('sinon-promise'); +sinonPromise(sinon); + +function create_res_mock() { + var status_mock = sinon.mock(); + var send_mock = sinon.mock(); + var set_mock = sinon.mock(); + var cookie_mock = sinon.mock(); + var render_mock = sinon.mock(); + var redirect_mock = sinon.mock(); + + return { + status: status_mock, + send: send_mock, + set: set_mock, + cookie: cookie_mock, + render: render_mock, + redirect: redirect_mock + }; +} diff --git a/app/tests/totp_checker_test.js b/app/tests/totp_checker_test.js new file mode 100644 index 000000000..3a6186966 --- /dev/null +++ b/app/tests/totp_checker_test.js @@ -0,0 +1,32 @@ + +var totp_checker = require('../lib/totp_checker'); +var sinon = require('sinon'); +var sinonPromise = require('sinon-promise'); +sinonPromise(sinon); + +var autoResolving = sinon.promise().resolves(); + +describe('test TOTP checker', function() { + it('should validate the TOTP token', function() { + var totp_secret = 'NBD2ZV64R9UV1O7K'; + var token = 'token'; + var totp_mock = sinon.mock(); + totp_mock.returns('token'); + var speakeasy_mock = { + totp: totp_mock + } + return totp_checker.validate(speakeasy_mock, token, totp_secret); + }); + + it('should not validate a wrong TOTP token', function() { + var totp_secret = 'NBD2ZV64R9UV1O7K'; + var token = 'wrong token'; + var totp_mock = sinon.mock(); + totp_mock.returns('token'); + var speakeasy_mock = { + totp: totp_mock + } + return totp_checker.validate(speakeasy_mock, token, totp_secret).fail(autoResolving); + }); +}); + diff --git a/app/views/login.ejs b/app/views/login.ejs new file mode 100644 index 000000000..e73433177 --- /dev/null +++ b/app/views/login.ejs @@ -0,0 +1,17 @@ + + + + + +
+

Login

+
+ + + + +
+
+
+ + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..e6378cc9d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,39 @@ + +version: '2' +services: + auth-server: + build: . + environment: + - LDAP_URL=ldap://ldap + - SECRET=NBD2ZV64R9UV1O7K + - USERS_DN=dc=example,dc=com + - PORT=80 + - EXPIRATION_TIME=2m + depends_on: + - ldap + expose: + - "80" + + ldap: + image: osixia/openldap:1.1.7 + environment: + - LDAP_ORGANISATION=MyCompany + - LDAP_DOMAIN=example.com + - LDAP_ADMIN_PASSWORD=test + expose: + - "389" + + nginx: + image: nginx:alpine + volumes: + - ./proxy/nginx.conf:/etc/nginx/nginx.conf + depends_on: + - auth-server + ports: + - "8085:80" + + secret: + image: nginx:alpine + volumes: + - ./secret/nginx.conf:/etc/nginx/nginx.conf + - ./secret/index.html:/usr/share/nginx/html/index.html diff --git a/package.json b/package.json new file mode 100644 index 000000000..8dd740e3a --- /dev/null +++ b/package.json @@ -0,0 +1,36 @@ +{ + "name": "ldap-totp-nginx-auth", + "version": "1.0.0", + "description": "", + "main": "app/index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/clems4ever/ldap-totp-nginx-auth.git" + }, + "author": "", + "license": "ISC", + "bugs": { + "url": "https://github.com/clems4ever/ldap-totp-nginx-auth/issues" + }, + "homepage": "https://github.com/clems4ever/ldap-totp-nginx-auth#readme", + "dependencies": { + "body-parser": "^1.15.2", + "cookie-parser": "^1.4.3", + "ejs": "^2.5.5", + "express": "^4.14.0", + "jsonwebtoken": "^7.2.1", + "ldapjs": "^1.0.1", + "object-path": "^0.11.3", + "q": "^1.4.1", + "speakeasy": "^2.0.0" + }, + "devDependencies": { + "mocha": "^3.2.0", + "should": "^11.1.1", + "sinon": "^1.17.6", + "sinon-promise": "^0.1.3" + } +} diff --git a/proxy/index.html b/proxy/index.html new file mode 100644 index 000000000..683471298 --- /dev/null +++ b/proxy/index.html @@ -0,0 +1,5 @@ + + +Coucou + + diff --git a/proxy/nginx.conf b/proxy/nginx.conf new file mode 100644 index 000000000..15c0dd333 --- /dev/null +++ b/proxy/nginx.conf @@ -0,0 +1,76 @@ +# nginx-sso - example nginx config +# +# (c) 2015 by Johannes Gilger +# +# This is an example config for using nginx with the nginx-sso cookie system. +# For simplicity, this config sets up two fictional vhosts that you can use to +# test against both components of the nginx-sso system: ssoauth & ssologin. +# In a real deployment, these vhosts would be separate hosts. + +#user nobody; +worker_processes 1; + +#error_log logs/error.log; +#error_log logs/error.log notice; +#error_log logs/error.log info; + +#pid logs/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + # This is the vserver for the service that you want to protect. + server { + listen 80; + + error_page 401 = @error401; + location @error401 { + return 302 http://127.0.0.1:8085/login; + } + + location = /_auth { + internal; + + proxy_pass http://auth-server/_auth; + + proxy_pass_request_body off; + + proxy_set_header Content-Length ""; + proxy_set_header X-Original-URI $request_uri; + proxy_set_header Host $http_host; + proxy_set_header X-Real-IP $remote_addr; + } + + location /secret/ { + auth_request /_auth; + + auth_request_set $user $upstream_http_x_remote_user; + proxy_set_header X-Forwarded-User $user; + # auth_request_set $groups $upstream_http_remote_groups; + # proxy_set_header Remote-Groups $groups; + # auth_request_set $expiry $upstream_http_remote_expiry; + # proxy_set_header Remote-Expiry $expiry; + + rewrite ^/secret/(.*)$ /$1 break; + proxy_pass http://secret; + } + + location /login { + 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-server/login; + } + + location /logout { + 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-server/logout; + } + } +} diff --git a/secret/index.html b/secret/index.html new file mode 100644 index 000000000..683471298 --- /dev/null +++ b/secret/index.html @@ -0,0 +1,5 @@ + + +Coucou + + diff --git a/secret/nginx.conf b/secret/nginx.conf new file mode 100644 index 000000000..b450c95c1 --- /dev/null +++ b/secret/nginx.conf @@ -0,0 +1,32 @@ +# nginx-sso - example nginx config +# +# (c) 2015 by Johannes Gilger +# +# This is an example config for using nginx with the nginx-sso cookie system. +# For simplicity, this config sets up two fictional vhosts that you can use to +# test against both components of the nginx-sso system: ssoauth & ssologin. +# In a real deployment, these vhosts would be separate hosts. + +#user nobody; +worker_processes 1; + +#error_log logs/error.log; +#error_log logs/error.log notice; +#error_log logs/error.log info; + +#pid logs/nginx.pid; + + +events { + worker_connections 1024; +} + + +http { + # This is the vserver for the service that you want to protect. + server { + listen 80; + + root /usr/share/nginx/html; + } +}