From 4b93338baed7f0968009f206eeaba019c3304fb9 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Tue, 21 Mar 2017 20:57:03 +0100 Subject: [PATCH 1/6] Move config adaptation into a module and make it testable --- config.template.yml | 3 ++ src/index.js | 17 +------ src/lib/config_adapter.js | 20 ++++++++ src/lib/server.js | 5 +- test/unitary/test_config_adapter.js | 67 +++++++++++++++++++++++++++ test/unitary/test_data_persistence.js | 12 +++-- test/unitary/test_server.js | 18 ++++--- test/unitary/test_server_config.js | 3 +- 8 files changed, 116 insertions(+), 29 deletions(-) create mode 100644 src/lib/config_adapter.js create mode 100644 test/unitary/test_config_adapter.js diff --git a/config.template.yml b/config.template.yml index e1ab5244..1f6a20e5 100644 --- a/config.template.yml +++ b/config.template.yml @@ -1,4 +1,7 @@ +# The port to listen on +port: 8080 + # Level of verbosity for logs logs_level: info diff --git a/src/index.js b/src/index.js index 0719b0ab..d50549a7 100755 --- a/src/index.js +++ b/src/index.js @@ -22,21 +22,6 @@ console.log('Parse configuration file: %s', config_path); var yaml_config = YAML.load(config_path); -var config = { - port: process.env.PORT || 8080, - ldap_url: yaml_config.ldap.url || 'ldap://127.0.0.1:389', - ldap_user_search_base: yaml_config.ldap.user_search_base, - ldap_user_search_filter: yaml_config.ldap.user_search_filter, - ldap_user: yaml_config.ldap.user, - ldap_password: yaml_config.ldap.password, - session_domain: yaml_config.session.domain, - session_secret: yaml_config.session.secret, - session_max_age: yaml_config.session.expiration || 3600000, // in ms - store_directory: yaml_config.store_directory, - logs_level: yaml_config.logs_level, - notifier: yaml_config.notifier, -} - var ldap_client = ldap.createClient({ url: config.ldap_url, reconnect: true @@ -53,4 +38,4 @@ deps.nodemailer = nodemailer; deps.ldap = ldap; deps.session = session; -server.run(config, ldap_client, deps); +server.run(yaml_config, ldap_client, deps); diff --git a/src/lib/config_adapter.js b/src/lib/config_adapter.js new file mode 100644 index 00000000..0fa48674 --- /dev/null +++ b/src/lib/config_adapter.js @@ -0,0 +1,20 @@ + +var objectPath = require('object-path'); + +module.exports = function(yaml_config) { + return { + port: objectPath.get(yaml_config, 'port', 8080), + ldap_url: objectPath.get(yaml_config, 'ldap.url', 'ldap://127.0.0.1:389'), + ldap_user_search_base: objectPath.get(yaml_config, 'ldap.user_search_base'), + ldap_user_search_filter: objectPath.get(yaml_config, 'ldap.user_search_filter'), + ldap_user: objectPath.get(yaml_config, 'ldap.user'), + ldap_password: objectPath.get(yaml_config, 'ldap.password'), + session_domain: objectPath.get(yaml_config, 'session.domain'), + session_secret: objectPath.get(yaml_config, 'session.secret'), + session_max_age: objectPath.get(yaml_config, 'session.expiration', 3600000), // in ms + store_directory: objectPath.get(yaml_config, 'store_directory'), + logs_level: objectPath.get(yaml_config, 'logs_level'), + notifier: objectPath.get(yaml_config, 'notifier'), + } +}; + diff --git a/src/lib/server.js b/src/lib/server.js index a828d670..745ca93f 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -12,8 +12,11 @@ var UserDataStore = require('./user_data_store'); var Notifier = require('./notifier'); var AuthenticationRegulator = require('./authentication_regulator'); var setup_endpoints = require('./setup_endpoints'); +var config_adapter = require('./config_adapter'); + +function run(yaml_config, ldap_client, deps, fn) { + var config = config_adapter(yaml_config); -function run(config, ldap_client, deps, fn) { var view_directory = path.resolve(__dirname, '../views'); var public_html_directory = path.resolve(__dirname, '../public_html'); var datastore_options = {}; diff --git a/test/unitary/test_config_adapter.js b/test/unitary/test_config_adapter.js new file mode 100644 index 00000000..70335df5 --- /dev/null +++ b/test/unitary/test_config_adapter.js @@ -0,0 +1,67 @@ + +var assert = require('assert'); +var config_adapter = require('../../src/lib/config_adapter'); + +describe('test config adapter', function() { + it('should read the port from the yaml file', function() { + yaml_config = {}; + yaml_config.port = 7070; + var config = config_adapter(yaml_config); + assert.equal(config.port, 7070); + }); + + it('should default the port to 8080 if not provided', function() { + yaml_config = {}; + var config = config_adapter(yaml_config); + assert.equal(config.port, 8080); + }); + + it('should get the ldap attributes', function() { + yaml_config = {}; + yaml_config.ldap = {}; + yaml_config.ldap.url = 'http://ldap'; + yaml_config.ldap.user_search_base = 'ou=groups,dc=example,dc=com'; + yaml_config.ldap.user_search_filter = 'uid'; + yaml_config.ldap.user = 'admin'; + yaml_config.ldap.password = 'pass'; + + var config = config_adapter(yaml_config); + + assert.equal(config.ldap_url, 'http://ldap'); + assert.equal(config.ldap_user_search_base, 'ou=groups,dc=example,dc=com'); + assert.equal(config.ldap_user_search_filter, 'uid'); + assert.equal(config.ldap_user, 'admin'); + assert.equal(config.ldap_password, 'pass'); + }); + + it('should get the session attributes', function() { + yaml_config = {}; + yaml_config.session = {}; + yaml_config.session.domain = 'example.com'; + yaml_config.session.secret = 'secret'; + yaml_config.session.expiration = 3600; + + var config = config_adapter(yaml_config); + + assert.equal(config.session_domain, 'example.com'); + assert.equal(config.session_secret, 'secret'); + assert.equal(config.session_max_age, 3600); + }); + + it('should get the log level', function() { + yaml_config = {}; + yaml_config.logs_level = 'debug'; + + var config = config_adapter(yaml_config); + assert.equal(config.logs_level, 'debug'); + }); + + it('should get the notifier config', function() { + yaml_config = {}; + yaml_config.notifier = 'notifier'; + + var config = config_adapter(yaml_config); + + assert.equal(config.notifier, 'notifier'); + }); +}); diff --git a/test/unitary/test_data_persistence.js b/test/unitary/test_data_persistence.js index a20b323a..ebf5268a 100644 --- a/test/unitary/test_data_persistence.js +++ b/test/unitary/test_data_persistence.js @@ -53,10 +53,14 @@ describe('test data persistence', function() { config = { port: PORT, totp_secret: 'totp_secret', - ldap_url: 'ldap://127.0.0.1:389', - ldap_user_search_base: 'ou=users,dc=example,dc=com', - session_secret: 'session_secret', - session_max_age: 50000, + ldap: { + url: 'ldap://127.0.0.1:389', + user_search_base: 'ou=users,dc=example,dc=com', + }, + session: { + secret: 'session_secret', + expiration: 50000, + }, store_directory: tmpDir.name, notifier: { gmail: { user: 'user@example.com', pass: 'password' } } }; diff --git a/test/unitary/test_server.js b/test/unitary/test_server.js index f9252b84..dea277b4 100644 --- a/test/unitary/test_server.js +++ b/test/unitary/test_server.js @@ -32,13 +32,17 @@ describe('test the server', function() { var config = { port: PORT, totp_secret: 'totp_secret', - ldap_url: 'ldap://127.0.0.1:389', - ldap_user_search_base: 'ou=users,dc=example,dc=com', - ldap_user_search_filter: 'cn', - ldap_user: 'cn=admin,dc=example,dc=com', - ldap_password: 'password', - session_secret: 'session_secret', - session_max_age: 50000, + ldap: { + url: 'ldap://127.0.0.1:389', + user_search_base: 'ou=users,dc=example,dc=com', + user_search_filter: 'cn', + user: 'cn=admin,dc=example,dc=com', + password: 'password', + }, + session: { + secret: 'session_secret', + expiration: 50000, + }, store_in_memory: true, notifier: { gmail: { diff --git a/test/unitary/test_server_config.js b/test/unitary/test_server_config.js index 4d6f9212..6f90f396 100644 --- a/test/unitary/test_server_config.js +++ b/test/unitary/test_server_config.js @@ -34,7 +34,8 @@ describe('test server configuration', function() { it('should set cookie scope to domain set in the config', function() { - config.session_domain = 'example.com'; + config.session = {}; + config.session.domain = 'example.com'; server.run(config, undefined, deps); assert(deps.session.calledOnce); From 2a73b1a4314fffbb7d5b469c3a3e7a087d490708 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 25 Mar 2017 15:17:21 +0100 Subject: [PATCH 2/6] Add the access_control entry in the config file to allow the user to define per group rules to access the subdomains --- config.template.yml | 45 ++++- docker-compose.yml | 3 + example/ldap/base.ldif | 70 ++++--- example/nginx_conf/nginx.conf | 2 +- src/index.js | 19 +- src/lib/config_adapter.js | 7 +- src/lib/ldap.js | 150 ++++++++++---- src/lib/routes/first_factor.js | 62 ++++-- src/lib/routes/reset_password.js | 19 +- src/lib/routes/verify.js | 20 ++ src/lib/server.js | 15 +- test/unitary/routes/test_first_factor.js | 91 +++++---- test/unitary/routes/test_reset_password.js | 35 ++-- test/unitary/routes/test_verify.js | 64 +++++- test/unitary/test_config_adapter.js | 19 +- test/unitary/test_data_persistence.js | 21 +- test/unitary/test_ldap.js | 217 +++++++++++++-------- test/unitary/test_server.js | 42 ++-- test/unitary/test_server_config.js | 9 +- 19 files changed, 620 insertions(+), 290 deletions(-) diff --git a/config.template.yml b/config.template.yml index 1f6a20e5..b84fb053 100644 --- a/config.template.yml +++ b/config.template.yml @@ -1,20 +1,57 @@ # The port to listen on -port: 8080 +port: 80 +# Log level +# # Level of verbosity for logs logs_level: info -# Configuration of LDAP +# LDAP configuration +# # Example: for user john, the DN will be cn=john,ou=users,dc=example,dc=com ldap: + # The url of the ldap server url: ldap://ldap - user_search_base: ou=users,dc=example,dc=com - user_search_filter: cn + + # The base dn for every entries + base_dn: dc=example,dc=com + + # An additional dn to define the scope to all users + additional_user_dn: ou=users + + # The user name attribute of users. Might uid for FreeIPA. 'cn' by default. + user_name_attribute: cn + + # An additional dn to define the scope of groups + additional_group_dn: ou=groups + + # The group name attribute of group. 'cn' by default. + group_name_attribute: cn + + # The username and password of the admin user. user: cn=admin,dc=example,dc=com password: password +# Access Control +# +# Access control is a set of rules where you can specify a group-based +# subdomain restrictions. +# +# If access_control is not defined, ACL rules are disabled and default policy +# is allowed to everyone. +# Otherwise, the default policy is denied for any user and any subdomain. +access_control: + - group: admin + allowed_domains: + - secret.test.local + - secret1.test.local + - group: dev + allowed_domains: + - secret2.test.local + + # Configuration of session cookies # # _secret_ the secret to encrypt session cookies diff --git a/docker-compose.yml b/docker-compose.yml index bf30b905..bfaaeb33 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,9 @@ services: - SLAPD_ORGANISATION=MyCompany - SLAPD_DOMAIN=example.com - SLAPD_PASSWORD=password + - SLAPD_ADDITIONAL_MODULES=memberof + - SLAPD_ADDITIONAL_SCHEMAS=openldap + - SLAPD_FORCE_RECONFIGURE=true expose: - "389" volumes: diff --git a/example/ldap/base.ldif b/example/ldap/base.ldif index 1e46c0ef..07d4e5a8 100644 --- a/example/ldap/base.ldif +++ b/example/ldap/base.ldif @@ -8,39 +8,55 @@ objectclass: organizationalUnit objectclass: top ou: users -dn: cn=user,ou=groups,dc=example,dc=com -cn: user -gidnumber: 502 -objectclass: posixGroup +dn: cn=dev,ou=groups,dc=example,dc=com +cn: dev +member: cn=john,ou=users,dc=example,dc=com +member: cn=bob,ou=users,dc=example,dc=com +objectclass: groupOfNames 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 +dn: cn=admin,ou=groups,dc=example,dc=com +cn: admin +member: cn=john,ou=users,dc=example,dc=com +objectclass: groupOfNames objectclass: top -mail: user@example.com -sn: User -uid: user -uidnumber: 1000 + +dn: cn=john,ou=users,dc=example,dc=com +cn: john +objectclass: inetOrgPerson +objectclass: top +mail: john.doe@example.com +sn: John Doe userpassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= -dn: uid=useruid,ou=users,dc=example,dc=com -cn: useruid -gidnumber: 500 -givenname: user -homedirectory: /home/user1 -loginshell: /bin/sh +dn: cn=harry,ou=users,dc=example,dc=com +cn: harry objectclass: inetOrgPerson -objectclass: posixAccount objectclass: top -mail: useruid@example.com -sn: User -uid: useruid -uidnumber: 1001 +mail: harry.potter@example.com +sn: Harry Potter userpassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= +dn: cn=bob,ou=users,dc=example,dc=com +cn: bob +objectclass: inetOrgPerson +objectclass: top +mail: bob.dylan@example.com +sn: Bob Dylan +userpassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= + +# dn: uid=jack,ou=users,dc=example,dc=com +# cn: jack +# gidnumber: 501 +# givenname: Jack +# homedirectory: /home/jack +# loginshell: /bin/sh +# objectclass: inetOrgPerson +# objectclass: posixAccount +# objectclass: top +# mail: jack.daniels@example.com +# sn: Jack Daniels +# uid: jack +# uidnumber: 1001 +# userpassword: {SHA}W6ph5Mm5Pz8GgiULbPgzG37mj9g= +# diff --git a/example/nginx_conf/nginx.conf b/example/nginx_conf/nginx.conf index 5ffafad6..cfb8c62e 100644 --- a/example/nginx_conf/nginx.conf +++ b/example/nginx_conf/nginx.conf @@ -73,8 +73,8 @@ http { location /authentication/verify { proxy_set_header X-Original-URI $request_uri; - proxy_set_header Host $http_host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header Host $http_host; proxy_pass http://auth/authentication/verify; } diff --git a/src/index.js b/src/index.js index d50549a7..e593e48b 100755 --- a/src/index.js +++ b/src/index.js @@ -4,12 +4,14 @@ process.env.NODE_TLS_REJECT_UNAUTHORIZED = "0"; var server = require('./lib/server'); -var ldap = require('ldapjs'); +var ldapjs = require('ldapjs'); var u2f = require('authdog'); var nodemailer = require('nodemailer'); var nedb = require('nedb'); var YAML = require('yamljs'); var session = require('express-session'); +var winston = require('winston'); +var speakeasy = require('speakeasy'); var config_path = process.argv[2]; if(!config_path) { @@ -22,20 +24,13 @@ console.log('Parse configuration file: %s', config_path); var yaml_config = YAML.load(config_path); -var ldap_client = ldap.createClient({ - url: config.ldap_url, - reconnect: true -}); - -ldap_client.on('error', function(err) { - console.error('LDAP Error:', err.message) -}) - var deps = {}; deps.u2f = u2f; deps.nedb = nedb; deps.nodemailer = nodemailer; -deps.ldap = ldap; +deps.ldapjs = ldapjs; deps.session = session; +deps.winston = winston; +deps.speakeasy = speakeasy; -server.run(yaml_config, ldap_client, deps); +server.run(yaml_config, deps); diff --git a/src/lib/config_adapter.js b/src/lib/config_adapter.js index 0fa48674..e91547f2 100644 --- a/src/lib/config_adapter.js +++ b/src/lib/config_adapter.js @@ -4,17 +4,14 @@ var objectPath = require('object-path'); module.exports = function(yaml_config) { return { port: objectPath.get(yaml_config, 'port', 8080), - ldap_url: objectPath.get(yaml_config, 'ldap.url', 'ldap://127.0.0.1:389'), - ldap_user_search_base: objectPath.get(yaml_config, 'ldap.user_search_base'), - ldap_user_search_filter: objectPath.get(yaml_config, 'ldap.user_search_filter'), - ldap_user: objectPath.get(yaml_config, 'ldap.user'), - ldap_password: objectPath.get(yaml_config, 'ldap.password'), + ldap: objectPath.get(yaml_config, 'ldap', 'ldap://127.0.0.1:389'), session_domain: objectPath.get(yaml_config, 'session.domain'), session_secret: objectPath.get(yaml_config, 'session.secret'), session_max_age: objectPath.get(yaml_config, 'session.expiration', 3600000), // in ms store_directory: objectPath.get(yaml_config, 'store_directory'), logs_level: objectPath.get(yaml_config, 'logs_level'), notifier: objectPath.get(yaml_config, 'notifier'), + access_control: objectPath.get(yaml_config, 'access_control') } }; diff --git a/src/lib/ldap.js b/src/lib/ldap.js index 01c5289e..0007dffc 100644 --- a/src/lib/ldap.js +++ b/src/lib/ldap.js @@ -1,46 +1,66 @@ -module.exports = { - validate: validate_credentials, - get_email: retrieve_email, - update_password: update_password -} +module.exports = Ldap; var util = require('util'); var Promise = require('bluebird'); var exceptions = require('./exceptions'); var Dovehash = require('dovehash'); -function validate_credentials(ldap_client, username, password, user_base, user_filter) { - // if not provided, default to cn - if(!user_filter) user_filter = 'cn'; +function Ldap(deps, ldap_config) { + this.ldap_config = ldap_config; - var userDN = util.format("%s=%s,%s", user_filter, username, user_base); - console.log(userDN); - var bind_promised = Promise.promisify(ldap_client.bind, { context: ldap_client }); - return bind_promised(userDN, password) + this.ldapjs = deps.ldapjs; + this.logger = deps.winston; + + this.connect(); +} + +Ldap.prototype.connect = function() { + var ldap_client = this.ldapjs.createClient({ + url: this.ldap_config.url, + reconnect: true + }); + + ldap_client.on('error', function(err) { + console.error('LDAP Error:', err.message) + }); + + this.ldap_client = Promise.promisifyAll(ldap_client); +} + +Ldap.prototype._build_user_dn = function(username) { + var user_name_attr = this.ldap_config.user_name_attribute; + // if not provided, default to cn + if(!user_name_attr) user_name_attr = 'cn'; + + var additional_user_dn = this.ldap_config.additional_user_dn; + var base_dn = this.ldap_config.base_dn; + + var user_dn = util.format("%s=%s", user_name_attr, username); + if(additional_user_dn) user_dn += util.format(",%s", additional_user_dn); + user_dn += util.format(',%s', base_dn); + return user_dn; +} + +Ldap.prototype.bind = function(username, password) { + var user_dn = this._build_user_dn(username); + + this.logger.debug('LDAP: Bind user %s', user_dn); + return this.ldap_client.bindAsync(user_dn, password) .error(function(err) { - console.error(err); throw new exceptions.LdapBindError(err.message); }); } -function retrieve_email(ldap_client, username, user_base, user_filter) { - // if not provided, default to cn - if(!user_filter) user_filter = 'cn'; - - var userDN = util.format("%s=%s,%s", user_filter, username, user_base); - console.log(userDN); - var search_promised = Promise.promisify(ldap_client.search, { context: ldap_client }); - var query = {}; - query.sizeLimit = 1; - query.attributes = ['mail']; - +Ldap.prototype._search_in_ldap = function(base, query) { + var that = this; + this.logger.debug('LDAP: Search for %s in %s', JSON.stringify(query), base); return new Promise(function(resolve, reject) { - search_promised(userDN, query) + that.ldap_client.searchAsync(base, query) .then(function(res) { - var doc; + var doc = []; res.on('searchEntry', function(entry) { - doc = entry.object; + doc.push(entry.object); }); res.on('error', function(err) { reject(new exceptions.LdapSearchError(err)); @@ -55,26 +75,80 @@ function retrieve_email(ldap_client, username, user_base, user_filter) { }); } -function update_password(ldap_client, ldap, username, new_password, config) { - var user_filter = config.ldap_user_search_filter; - // if not provided, default to cn - if(!user_filter) user_filter = 'cn'; +Ldap.prototype.get_groups = function(username) { + var user_dn = this._build_user_dn(username); + + var group_name_attr = this.ldap_config.group_name_attribute; + if(!group_name_attr) group_name_attr = 'cn'; + + var additional_group_dn = this.ldap_config.additional_group_dn; + var base_dn = this.ldap_config.base_dn; + + var group_dn = base_dn; + if(additional_group_dn) + group_dn = util.format('%s,', additional_group_dn) + group_dn; + + var query = {}; + query.scope = 'sub'; + query.attributes = [group_name_attr]; + query.filter = 'member=' + user_dn ; + + var that = this; + this.logger.debug('LDAP: get groups of user %s', username); + return this._search_in_ldap(group_dn, query) + .then(function(docs) { + var groups = []; + for(var i = 0; i= 0) { + var domains = rule.allowed_domains; + allowed_domains = allowed_domains.concat(domains); + } + } + } + return allowed_domains; +} function first_factor(req, res) { - var logger = req.app.get('logger'); var username = req.body.username; var password = req.body.password; if(!username || !password) { @@ -15,59 +29,69 @@ 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 logger = req.app.get('logger'); + var ldap = req.app.get('ldap'); var config = req.app.get('config'); var regulator = req.app.get('authentication regulator'); + logger.info('1st factor: Starting authentication of user "%s"', username); 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_user_search_base); - logger.debug('1st factor: user_filter=%s', config.ldap_user_search_filter); regulator.regulate(username) .then(function() { - return ldap.validate(ldap_client, username, password, config.ldap_user_search_base, config.ldap_user_search_filter); + return ldap.bind(username, password); }) .then(function() { objectPath.set(req, 'session.auth_session.userid', username); objectPath.set(req, 'session.auth_session.first_factor', true); logger.info('1st factor: LDAP binding successful'); logger.debug('1st factor: Retrieve email from LDAP'); - return ldap.get_email(ldap_client, username, config.ldap_user_search_base, - config.ldap_user_search_filter) + return Promise.join(ldap.get_emails(username), ldap.get_groups(username)); }) - .then(function(doc) { - var email = objectPath.get(doc, 'mail'); - logger.debug('1st factor: document=%s', JSON.stringify(doc)); - logger.debug('1st factor: Retrieved email is %s', email); + .then(function(data) { + var emails = data[0]; + var groups = data[1]; + + if(!emails && emails.length <= 0) throw new Error('No email found'); + logger.debug('1st factor: Retrieved email are %s', emails); + objectPath.set(req, 'session.auth_session.email', emails[0]); + + if(config.access_control) { + var allowed_domains = get_allowed_domains(config.access_control, groups); + logger.debug('1st factor: allowed domains are %s', allowed_domains); + objectPath.set(req, 'session.auth_session.allowed_domains', + allowed_domains); + } + else { + logger.debug('1st factor: no access control rules found.' + + 'Default policy to allow all.'); + } - objectPath.set(req, 'session.auth_session.email', email); regulator.mark(username, true); res.status(204); res.send(); }) .catch(exceptions.LdapSearchError, function(err) { - logger.info('1st factor: Unable to retrieve email from LDAP', err); + logger.error('1st factor: Unable to retrieve email from LDAP', err); res.status(500); res.send(); }) .catch(exceptions.LdapBindError, function(err) { - logger.info('1st factor: LDAP binding failed'); + logger.error('1st factor: LDAP binding failed'); logger.debug('1st factor: LDAP binding failed due to ', err); regulator.mark(username, false); res.status(401); res.send('Bad credentials'); }) .catch(exceptions.AuthenticationRegulationError, function(err) { - logger.info('1st factor: the regulator rejected the authentication of user %s', username); + logger.error('1st factor: the regulator rejected the authentication of user %s', username); logger.debug('1st factor: authentication rejected due to %s', err); res.status(403); res.send('Access has been restricted for a few minutes...'); }) .catch(function(err) { - logger.debug('1st factor: Unhandled error %s', err); + logger.error('1st factor: Unhandled error %s', err); res.status(500); res.send('Internal error'); }); diff --git a/src/lib/routes/reset_password.js b/src/lib/routes/reset_password.js index 8b46cf15..dd26bf5c 100644 --- a/src/lib/routes/reset_password.js +++ b/src/lib/routes/reset_password.js @@ -1,7 +1,6 @@ var Promise = require('bluebird'); var objectPath = require('object-path'); -var ldap = require('../ldap'); var exceptions = require('../exceptions'); var CHALLENGE = 'reset-password'; @@ -24,16 +23,14 @@ function pre_check(req) { return Promise.reject(err); } - var ldap_client = req.app.get('ldap client'); - var config = req.app.get('config'); + var ldap = req.app.get('ldap'); - return ldap.get_email(ldap_client, userid, config.ldap_user_search_base, - config.ldap_user_search_filter) - .then(function(doc) { - var email = objectPath.get(doc, 'mail'); + return ldap.get_emails(userid) + .then(function(emails) { + if(!emails && emails.length <= 0) throw new Error('No email found'); var identity = {} - identity.email = email; + identity.email = emails[0]; identity.userid = userid; return Promise.resolve(identity); }); @@ -53,15 +50,13 @@ function protect(fn) { function post(req, res) { var logger = req.app.get('logger'); - var ldapjs = req.app.get('ldap'); - var ldap_client = req.app.get('ldap client'); + var ldap = req.app.get('ldap'); var new_password = objectPath.get(req, 'body.password'); var userid = objectPath.get(req, 'session.auth_session.identity_check.userid'); - var config = req.app.get('config'); logger.info('POST reset-password: User %s wants to reset his/her password', userid); - ldap.update_password(ldap_client, ldapjs, userid, new_password, config) + ldap.update_password(userid, new_password) .then(function() { logger.info('POST reset-password: Password reset for user %s', userid); objectPath.set(req, 'session.auth_session', undefined); diff --git a/src/lib/routes/verify.js b/src/lib/routes/verify.js index 06332827..e1be3764 100644 --- a/src/lib/routes/verify.js +++ b/src/lib/routes/verify.js @@ -5,6 +5,8 @@ var objectPath = require('object-path'); var Promise = require('bluebird'); function verify_filter(req, res) { + var logger = req.app.get('logger'); + if(!objectPath.has(req, 'session.auth_session')) return Promise.reject('No auth_session variable'); @@ -14,6 +16,23 @@ function verify_filter(req, res) { if(!objectPath.has(req, 'session.auth_session.second_factor')) return Promise.reject('No second factor variable'); + if(!objectPath.has(req, 'session.auth_session.userid')) + return Promise.reject('No userid variable'); + + var config = req.app.get('config'); + var access_control = config.access_control; + + if(access_control) { + var allowed_domains = objectPath.get(req, 'session.auth_session.allowed_domains'); + var host = objectPath.get(req, 'headers.host'); + var domain = host.split(':')[0]; + logger.debug('Trying to access domain: %s', domain); + logger.debug('User has access to %s', JSON.stringify(allowed_domains)); + + if(allowed_domains.indexOf(domain) < 0) + return Promise.reject('Access restricted by ACL rules'); + } + if(!req.session.auth_session.first_factor || !req.session.auth_session.second_factor) return Promise.reject('First or second factor not validated'); @@ -28,6 +47,7 @@ function verify(req, res) { res.send(); }) .catch(function(err) { + req.app.get('logger').error(err); res.status(401); res.send(); }); diff --git a/src/lib/server.js b/src/lib/server.js index 745ca93f..1aa51b64 100644 --- a/src/lib/server.js +++ b/src/lib/server.js @@ -5,16 +5,15 @@ module.exports = { var express = require('express'); var bodyParser = require('body-parser'); -var speakeasy = require('speakeasy'); var path = require('path'); -var winston = require('winston'); var UserDataStore = require('./user_data_store'); var Notifier = require('./notifier'); var AuthenticationRegulator = require('./authentication_regulator'); var setup_endpoints = require('./setup_endpoints'); var config_adapter = require('./config_adapter'); +var Ldap = require('./ldap'); -function run(yaml_config, ldap_client, deps, fn) { +function run(yaml_config, deps, fn) { var config = config_adapter(yaml_config); var view_directory = path.resolve(__dirname, '../views'); @@ -45,17 +44,17 @@ function run(yaml_config, ldap_client, deps, fn) { app.set('view engine', 'ejs'); // by default the level of logs is info - winston.level = config.logs_level || 'info'; + deps.winston.level = config.logs_level || 'info'; var five_minutes = 5 * 60; var data_store = new UserDataStore(deps.nedb, datastore_options); var regulator = new AuthenticationRegulator(data_store, five_minutes); var notifier = new Notifier(config.notifier, deps); + var ldap = new Ldap(deps, config.ldap); - app.set('logger', winston); - app.set('ldap', deps.ldap); - app.set('ldap client', ldap_client); - app.set('totp engine', speakeasy); + app.set('logger', deps.winston); + app.set('ldap', ldap); + app.set('totp engine', deps.speakeasy); app.set('u2f', deps.u2f); app.set('user data store', data_store); app.set('notifier', notifier); diff --git a/test/unitary/routes/test_first_factor.js b/test/unitary/routes/test_first_factor.js index 548b6dd7..4798f982 100644 --- a/test/unitary/routes/test_first_factor.js +++ b/test/unitary/routes/test_first_factor.js @@ -5,35 +5,28 @@ var assert = require('assert'); var winston = require('winston'); var first_factor = require('../../../src/lib/routes/first_factor'); var exceptions = require('../../../src/lib/exceptions'); +var Ldap = require('../../../src/lib/ldap'); describe('test the first factor validation route', function() { var req, res; var ldap_interface_mock; + var emails; var search_res_ok; var regulator; + var config; beforeEach(function() { - ldap_interface_mock = { - bind: sinon.stub(), - search: sinon.stub() - } - var config = { - ldap_user_search_base: 'ou=users,dc=example,dc=com', - ldap_user_search_filter: 'uid' - } - - var search_doc = { - object: { - mail: 'test_ok@example.com' + ldap_interface_mock = sinon.createStubInstance(Ldap); + config = { + ldap: { + base_dn: 'ou=users,dc=example,dc=com', + user_name_attribute: 'uid' } - }; - - 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); + } + emails = [ 'test_ok@example.com' ]; + groups = [ 'group1', 'group2' ]; + regulator = {}; regulator.mark = sinon.stub(); regulator.regulate = sinon.stub(); @@ -42,7 +35,7 @@ describe('test the first factor validation route', function() { regulator.regulate.returns(Promise.resolve()); var app_get = sinon.stub(); - app_get.withArgs('ldap client').returns(ldap_interface_mock); + app_get.withArgs('ldap').returns(ldap_interface_mock); app_get.withArgs('config').returns(config); app_get.withArgs('logger').returns(winston); app_get.withArgs('authentication regulator').returns(regulator); @@ -75,43 +68,69 @@ describe('test the first factor validation route', function() { assert.equal(204, res.status.getCall(0).args[0]); resolve(); }); - ldap_interface_mock.bind.yields(undefined); + ldap_interface_mock.bind.withArgs('username').returns(Promise.resolve()); + ldap_interface_mock.get_emails.returns(Promise.resolve(emails)); first_factor(req, res); }); }); - it('should bind user based on LDAP DN', function(done) { - ldap_interface_mock.bind = sinon.spy(function(dn) { - if(dn == 'uid=username,ou=users,dc=example,dc=com') done(); + it('should store the allowed domains in the auth session', function() { + config.access_control = []; + config.access_control.push({ + group: 'group1', + allowed_domains: ['domain1.example.com', 'domain2.example.com'] + }); + return new Promise(function(resolve, reject) { + res.send = sinon.spy(function(data) { + assert.deepEqual(['domain1.example.com', 'domain2.example.com'], + req.session.auth_session.allowed_domains); + assert.equal(204, res.status.getCall(0).args[0]); + resolve(); + }); + ldap_interface_mock.bind.withArgs('username').returns(Promise.resolve()); + ldap_interface_mock.get_emails.returns(Promise.resolve(emails)); + ldap_interface_mock.get_groups.returns(Promise.resolve(groups)); + first_factor(req, res); }); - first_factor(req, res); }); it('should retrieve email from LDAP', function(done) { - ldap_interface_mock.bind.yields(undefined); - ldap_interface_mock.search = sinon.spy(function(dn) { - if(dn == 'uid=username,ou=users,dc=example,dc=com') done(); - }); + res.send = sinon.spy(function(data) { done(); }); + ldap_interface_mock.bind.returns(Promise.resolve()); + ldap_interface_mock.get_emails = sinon.stub().withArgs('usernam').returns(Promise.resolve([{mail: ['test@example.com'] }])); first_factor(req, res); }); - it('should return status code 401 when LDAP binding fails', function(done) { + it('should set email as session variables', function() { + return new Promise(function(resolve, reject) { + res.send = sinon.spy(function(data) { + assert.equal('test_ok@example.com', req.session.auth_session.email); + resolve(); + }); + var emails = [ 'test_ok@example.com' ]; + ldap_interface_mock.bind.returns(Promise.resolve()); + ldap_interface_mock.get_emails.returns(Promise.resolve(emails)); + first_factor(req, res); + }); + }); + + it('should return status code 401 when LDAP binding throws', function(done) { res.send = sinon.spy(function(data) { assert.equal(401, res.status.getCall(0).args[0]); assert.equal(regulator.mark.getCall(0).args[0], 'username'); done(); }); - ldap_interface_mock.bind.yields('Bad credentials'); + ldap_interface_mock.bind.throws(new exceptions.LdapBindError('Bad credentials')); first_factor(req, res); }); - it('should return status code 500 when LDAP binding throws', function(done) { + it('should return status code 500 when LDAP search throws', function(done) { res.send = sinon.spy(function(data) { assert.equal(500, res.status.getCall(0).args[0]); done(); }); - ldap_interface_mock.bind.yields(undefined); - ldap_interface_mock.search.yields('error'); + ldap_interface_mock.bind.returns(Promise.resolve()); + ldap_interface_mock.get_emails.throws(new exceptions.LdapSearchError('err')); first_factor(req, res); }); @@ -122,8 +141,8 @@ describe('test the first factor validation route', function() { assert.equal(403, res.status.getCall(0).args[0]); done(); }); - ldap_interface_mock.bind.yields(undefined); - ldap_interface_mock.search.yields(undefined); + ldap_interface_mock.bind.returns(Promise.resolve()); + ldap_interface_mock.get_emails.returns(Promise.resolve()); first_factor(req, res); }); }); diff --git a/test/unitary/routes/test_reset_password.js b/test/unitary/routes/test_reset_password.js index 14bff5a3..efac684d 100644 --- a/test/unitary/routes/test_reset_password.js +++ b/test/unitary/routes/test_reset_password.js @@ -1,6 +1,8 @@ +var reset_password = require('../../../src/lib/routes/reset_password'); +var Ldap = require('../../../src/lib/ldap'); + var sinon = require('sinon'); var winston = require('winston'); -var reset_password = require('../../../src/lib/routes/reset_password'); var assert = require('assert'); describe('test reset password', function() { @@ -35,20 +37,30 @@ describe('test reset password', function() { user_data_store.consume_identity_check_token = sinon.stub().returns(Promise.resolve({})); req.app.get.withArgs('user data store').returns(user_data_store); - ldap = {}; - ldap.Change = sinon.spy(); - req.app.get.withArgs('ldap').returns(ldap); + + config = {}; + config.ldap = {}; + config.ldap.base_dn = 'dc=example,dc=com'; + config.ldap.user_name_attribute = 'cn'; + req.app.get.withArgs('config').returns(config); ldap_client = {}; ldap_client.bind = sinon.stub(); ldap_client.search = sinon.stub(); ldap_client.modify = sinon.stub(); - req.app.get.withArgs('ldap client').returns(ldap_client); + ldap_client.on = sinon.spy(); - config = {}; - config.ldap_user_search_base = 'dc=example,dc=com'; - config.ldap_user_search_filter = 'cn'; - req.app.get.withArgs('config').returns(config); + ldapjs = {}; + ldapjs.Change = sinon.spy(); + ldapjs.createClient = sinon.spy(function() { + return ldap_client; + }); + + deps = { + ldapjs: ldapjs, + winston: winston + }; + req.app.get.withArgs('ldap').returns(new Ldap(deps, config.ldap)); res = {}; res.send = sinon.spy(); @@ -77,9 +89,8 @@ describe('test reset password', function() { }); it('should perform a search in ldap to find email address', function(done) { - config.ldap_user_search_filter = 'uid'; + config.ldap.user_name_attribute = 'uid'; ldap_client.search = sinon.spy(function(dn) { - console.log(dn); if(dn == 'uid=user,dc=example,dc=com') done(); }); reset_password.icheck_interface.pre_check_callback(req); @@ -88,7 +99,7 @@ describe('test reset password', function() { it('should returns identity when ldap replies', function(done) { var doc = {}; doc.object = {}; - doc.object.email = 'test@example.com'; + doc.object.email = ['test@example.com']; doc.object.userid = 'user'; var res = {}; diff --git a/test/unitary/routes/test_verify.js b/test/unitary/routes/test_verify.js index 2e057a1d..38c08beb 100644 --- a/test/unitary/routes/test_verify.js +++ b/test/unitary/routes/test_verify.js @@ -2,19 +2,33 @@ var assert = require('assert'); var verify = require('../../../src/lib/routes/verify'); var sinon = require('sinon'); +var winston = require('winston'); describe('test authentication token verification', function() { var req, res; + var config_mock; beforeEach(function() { + config_mock = {}; req = {}; res = {}; + req.headers = {}; + req.headers.host = 'secret.example.com'; + req.app = {}; + req.app.get = sinon.stub(); + req.app.get.withArgs('config').returns(config_mock); + req.app.get.withArgs('logger').returns(winston); res.status = sinon.spy(); }); it('should be already authenticated', function(done) { req.session = {}; - req.session.auth_session = {first_factor: true, second_factor: true}; + req.session.auth_session = { + first_factor: true, + second_factor: true, + userid: 'myuser', + group: 'mygroup' + }; res.send = sinon.spy(function() { assert.equal(204, res.status.getCall(0).args[0]); @@ -25,13 +39,13 @@ describe('test authentication token verification', function() { }); describe('given different cases of session', function() { - function test_unauthorized(auth_session) { + function test_session(auth_session, status_code) { return new Promise(function(resolve, reject) { req.session = {}; req.session.auth_session = auth_session; res.send = sinon.spy(function() { - assert.equal(401, res.status.getCall(0).args[0]); + assert.equal(status_code, res.status.getCall(0).args[0]); resolve(); }); @@ -39,6 +53,14 @@ describe('test authentication token verification', function() { }); } + function test_unauthorized(auth_session) { + return test_session(auth_session, 401); + } + + function test_authorized(auth_session) { + return test_session(auth_session, 204); + } + it('should not be authenticated when second factor is missing', function() { return test_unauthorized({ first_factor: true, second_factor: false }); }); @@ -47,6 +69,14 @@ describe('test authentication token verification', function() { return test_unauthorized({ first_factor: false, second_factor: true }); }); + it('should not be authenticated when userid is missing', function() { + return test_unauthorized({ + first_factor: true, + second_factor: true, + group: 'mygroup', + }); + }); + it('should not be authenticated when first and second factor are missing', function() { return test_unauthorized({ first_factor: false, second_factor: false }); }); @@ -55,6 +85,34 @@ describe('test authentication token verification', function() { return test_unauthorized(undefined); }); + it('should reply unauthorized when the domain is restricted', function() { + config_mock.access_control = []; + config_mock.access_control.push({ + group: 'abc', + allowed_domains: ['secret.example.com'] + }); + return test_unauthorized({ + first_factor: true, + second_factor: true, + userid: 'user', + allowed_domains: ['restricted.example.com'] + }); + }); + + it('should reply authorized when the domain is allowed', function() { + config_mock.access_control = []; + config_mock.access_control.push({ + group: 'abc', + allowed_domains: ['secret.example.com'] + }); + return test_authorized({ + first_factor: true, + second_factor: true, + userid: 'user', + allowed_domains: ['secret.example.com'] + }); + }); + it('should not be authenticated when session is partially initialized', function() { return test_unauthorized({ first_factor: true }); }); diff --git a/test/unitary/test_config_adapter.js b/test/unitary/test_config_adapter.js index 70335df5..5ffcc84a 100644 --- a/test/unitary/test_config_adapter.js +++ b/test/unitary/test_config_adapter.js @@ -27,11 +27,11 @@ describe('test config adapter', function() { var config = config_adapter(yaml_config); - assert.equal(config.ldap_url, 'http://ldap'); - assert.equal(config.ldap_user_search_base, 'ou=groups,dc=example,dc=com'); - assert.equal(config.ldap_user_search_filter, 'uid'); - assert.equal(config.ldap_user, 'admin'); - assert.equal(config.ldap_password, 'pass'); + assert.equal(config.ldap.url, 'http://ldap'); + assert.equal(config.ldap.user_search_base, 'ou=groups,dc=example,dc=com'); + assert.equal(config.ldap.user_search_filter, 'uid'); + assert.equal(config.ldap.user, 'admin'); + assert.equal(config.ldap.password, 'pass'); }); it('should get the session attributes', function() { @@ -64,4 +64,13 @@ describe('test config adapter', function() { assert.equal(config.notifier, 'notifier'); }); + + it('should get the access_control config', function() { + yaml_config = {}; + yaml_config.access_control = 'access_control'; + + var config = config_adapter(yaml_config); + + assert.equal(config.access_control, 'access_control'); + }); }); diff --git a/test/unitary/test_data_persistence.js b/test/unitary/test_data_persistence.js index ebf5268a..35c2c980 100644 --- a/test/unitary/test_data_persistence.js +++ b/test/unitary/test_data_persistence.js @@ -9,6 +9,7 @@ var sinon = require('sinon'); var tmp = require('tmp'); var nedb = require('nedb'); var session = require('express-session'); +var winston = require('winston'); var PORT = 8050; var BASE_URL = 'http://localhost:' + PORT; @@ -21,8 +22,14 @@ describe('test data persistence', function() { var tmpDir; var ldap_client = { bind: sinon.stub(), - search: sinon.stub() + search: sinon.stub(), + on: sinon.spy() }; + var ldap = { + createClient: sinon.spy(function() { + return ldap_client; + }) + } var config; before(function() { @@ -55,7 +62,7 @@ describe('test data persistence', function() { totp_secret: 'totp_secret', ldap: { url: 'ldap://127.0.0.1:389', - user_search_base: 'ou=users,dc=example,dc=com', + base_dn: 'ou=users,dc=example,dc=com', }, session: { secret: 'session_secret', @@ -94,11 +101,13 @@ describe('test data persistence', function() { deps.nedb = nedb; deps.nodemailer = nodemailer; deps.session = session; + deps.winston = winston; + deps.ldapjs = ldap; var j1 = request.jar(); var j2 = request.jar(); - return start_server(config, ldap_client, deps) + return start_server(config, deps) .then(function(s) { server = s; return requests.login(j1); @@ -116,7 +125,7 @@ describe('test data persistence', function() { return stop_server(server); }) .then(function() { - return start_server(config, ldap_client, deps) + return start_server(config, deps) }) .then(function(s) { server = s; @@ -139,9 +148,9 @@ describe('test data persistence', function() { }); }); - function start_server(config, ldap_client, deps) { + function start_server(config, deps) { return new Promise(function(resolve, reject) { - var s = server.run(config, ldap_client, deps); + var s = server.run(config, deps); resolve(s); }); } diff --git a/test/unitary/test_ldap.js b/test/unitary/test_ldap.js index 9d301d23..c7fff8f6 100644 --- a/test/unitary/test_ldap.js +++ b/test/unitary/test_ldap.js @@ -1,185 +1,232 @@ -var ldap = require('../../src/lib/ldap'); +var Ldap = require('../../src/lib/ldap'); var sinon = require('sinon'); var Promise = require('bluebird'); var assert = require('assert'); +var ldapjs = require('ldapjs'); +var winston = require('winston'); describe('test ldap validation', function() { var ldap_client; + var ldap, ldapjs; + var ldap_config; beforeEach(function() { ldap_client = { bind: sinon.stub(), search: sinon.stub(), modify: sinon.stub(), - Change: sinon.spy() + on: sinon.stub() + }; + + ldapjs = { + Change: sinon.spy(), + createClient: sinon.spy(function() { + return ldap_client; +  }) } + ldap_config = { + url: 'http://localhost:324', + user: 'admin', + password: 'password', + base_dn: 'dc=example,dc=com', + additional_user_dn: 'ou=users' + }; + + var deps = {}; + deps.ldapjs = ldapjs; + deps.winston = winston; + + ldap = new Ldap(deps, ldap_config); + return ldap.connect(); }); describe('test binding', test_binding); - describe('test get email', test_get_email); + describe('test get emails from username', test_get_emails); + describe('test get groups from username', test_get_groups); describe('test update password', test_update_password); function test_binding() { - function test_validate() { - var username = 'user'; - var password = 'password'; - var users_dn = 'dc=example,dc=com'; - return ldap.validate(ldap_client, username, password, users_dn); + function test_bind() { + var username = "username"; + var password = "password"; + return ldap.bind(username, password); } it('should bind the user if good credentials provided', function() { ldap_client.bind.yields(); - return test_validate(); + return test_bind(); }); - it('should bind the user with correct DN', function(done) { + it('should bind the user with correct DN', function() { + ldap_config.user_name_attribute = 'uid'; var username = 'user'; var password = 'password'; - var user_search_base = 'dc=example,dc=com'; - var user_search_filter = 'uid'; - ldap_client.bind = sinon.spy(function(dn) { - if(dn == 'uid=user,dc=example,dc=com') done(); - }); - ldap.validate(ldap_client, username, password, user_search_base, - user_search_filter); + ldap_client.bind.withArgs('uid=user,ou=users,dc=example,dc=com').yields(); + return ldap.bind(username, password); }); - it('should default to cn user search filter if no filter provided', function(done) { + it('should default to cn user search filter if no filter provided', function() { var username = 'user'; var password = 'password'; - var user_search_base = 'dc=example,dc=com'; - ldap_client.bind = sinon.spy(function(dn) { - if(dn == 'cn=user,dc=example,dc=com') done(); - }); - ldap.validate(ldap_client, username, password, user_search_base, - undefined); - }); - - // 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(); + ldap_client.bind.withArgs('cn=user,ou=users,dc=example,dc=com').yields(); + return ldap.bind(username, password); }); it('should not bind the user if wrong credentials provided', function() { ldap_client.bind.yields('wrong credentials'); - var promise = test_validate(); + var promise = test_bind(); return promise.catch(function() { return Promise.resolve(); }); }); } - function test_get_email() { - it('should retrieve the email of an existing user', function() { - var expected_doc = {}; + function test_get_emails() { + var res_emitter; + var expected_doc; + + beforeEach(function() { + expected_doc = {}; expected_doc.object = {}; expected_doc.object.mail = 'user@example.com'; - var res_emitter = {}; + + res_emitter = {}; res_emitter.on = sinon.spy(function(event, fn) { if(event != 'error') fn(expected_doc) }); + }); + it('should retrieve the email of an existing user', function() { ldap_client.search.yields(undefined, res_emitter); - return ldap.get_email(ldap_client, 'user', 'dc=example,dc=com') - .then(function(doc) { - assert.deepEqual(doc, expected_doc.object); + return ldap.get_emails('user') + .then(function(emails) { + assert.deepEqual(emails, [expected_doc.object.mail]); return Promise.resolve(); }) }); - it('should use the user filter', function(done) { - ldap_client.search = sinon.spy(function(dn) { - if(dn == 'uid=username,ou=users,dc=example,dc=com') done(); + it('should retrieve email for user with uid name attribute', function() { + ldap_config.user_name_attribute = 'uid'; + ldap_client.search.withArgs('uid=username,ou=users,dc=example,dc=com').yields(undefined, res_emitter); + return ldap.get_emails('username') + .then(function(emails) { + assert.deepEqual(emails, ['user@example.com']); + return Promise.resolve(); }); - ldap.get_email(ldap_client, 'username', 'ou=users,dc=example,dc=com', - 'uid') }); - it('should fail on error with search method', function(done) { + it('should fail on error with search method', function() { 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') + return ldap.get_emails('user') .catch(function() { + return Promise.resolve(); + }) + }); + } + + function test_get_groups() { + var res_emitter; + var expected_doc1, expected_doc2; + + beforeEach(function() { + expected_doc1 = {}; + expected_doc1.object = {}; + expected_doc1.object.cn = 'group1'; + + expected_doc2 = {}; + expected_doc2.object = {}; + expected_doc2.object.cn = 'group2'; + + res_emitter = {}; + res_emitter.on = sinon.spy(function(event, fn) { + if(event != 'error') fn(expected_doc1); + if(event != 'error') fn(expected_doc2); + }); + }); + + it('should retrieve the groups of an existing user', function() { + ldap_client.search.yields(undefined, res_emitter); + return ldap.get_groups('user') + .then(function(groups) { + assert.deepEqual(groups, ['group1', 'group2']); + return Promise.resolve(); + }); + }); + + it('should reduce the scope to additional_group_dn', function(done) { + ldap_config.additional_group_dn = 'ou=groups'; + ldap_client.search = sinon.spy(function(base_dn) { + assert.equal(base_dn, 'ou=groups,dc=example,dc=com'); done(); + }); + ldap.get_groups('user'); + }); + + it('should use default group_name_attr if not provided', function(done) { + ldap_client.search = sinon.spy(function(base_dn, query) { + assert.equal(base_dn, 'dc=example,dc=com'); + assert.equal(query.filter, 'member=cn=user,ou=users,dc=example,dc=com'); + assert.deepEqual(query.attributes, ['cn']); + done(); + }); + ldap.get_groups('user'); + }); + + it('should fail on error with search method', function() { + ldap_client.search.yields('error'); + return ldap.get_groups('user') + .catch(function() { + return Promise.resolve(); }) }); } function test_update_password() { - it('should update the password successfully', function(done) { + it('should update the password successfully', function() { var change = {}; change.operation = 'replace'; change.modification = {}; change.modification.userPassword = 'new-password'; - var config = {}; - config.ldap_user_search_base = 'dc=example,dc=com'; - config.ldap_user = 'admin'; - - var userdn = 'cn=user,dc=example,dc=com'; - - var ldapjs = {}; - ldapjs.Change = sinon.spy(); + var userdn = 'cn=user,ou=users,dc=example,dc=com'; ldap_client.bind.yields(undefined); ldap_client.modify.yields(undefined); - ldap.update_password(ldap_client, ldapjs, 'user', 'new-password', config) + return ldap.update_password('user', 'new-password') .then(function() { assert.deepEqual(ldap_client.modify.getCall(0).args[0], userdn); assert.deepEqual(ldapjs.Change.getCall(0).args[0].operation, change.operation); var userPassword = ldapjs.Change.getCall(0).args[0].modification.userPassword; assert(/{SSHA}/.test(userPassword)); - done(); + return Promise.resolve(); }) }); - it('should fail when ldap throws an error', function(done) { + it('should fail when ldap throws an error', function() { ldap_client.bind.yields(undefined); ldap_client.modify.yields('Error'); - var config = {}; - config.ldap_users_dn = 'dc=example,dc=com'; - config.ldap_user = 'admin'; - - var ldapjs = {}; - ldapjs.Change = sinon.spy(); - - ldap.update_password(ldap_client, ldapjs, 'user', 'new-password', config) + return ldap.update_password('user', 'new-password') .catch(function() { - done(); + return Promise.resolve(); }) }); - it('should use the user filter', function(done) { - var ldapjs = {}; - ldapjs.Change = sinon.spy(); - - var config = {}; - config.ldap_user_search_base = 'ou=users,dc=example,dc=com'; - config.ldap_user_search_filter = 'uid'; - config.ldap_user = 'admin'; + it('should update password of user using particular user name attribute', function() { + ldap_config.user_name_attribute = 'uid'; ldap_client.bind.yields(undefined); - ldap_client.modify = sinon.spy(function(dn) { - if(dn == 'uid=username,ou=users,dc=example,dc=com') done(); - }); - ldap.update_password(ldap_client, ldapjs, 'username', 'newpass', config) + ldap_client.modify.withArgs('uid=username,ou=users,dc=example,dc=com').yields(); + return ldap.update_password('username', 'newpass'); }); } }); diff --git a/test/unitary/test_server.js b/test/unitary/test_server.js index dea277b4..33bf25c9 100644 --- a/test/unitary/test_server.js +++ b/test/unitary/test_server.js @@ -1,5 +1,6 @@ var server = require('../../src/lib/server'); +var Ldap = require('../../src/lib/ldap'); var Promise = require('bluebird'); var request = Promise.promisifyAll(require('request')); @@ -8,6 +9,9 @@ var speakeasy = require('speakeasy'); var sinon = require('sinon'); var MockDate = require('mockdate'); var session = require('express-session'); +var winston = require('winston'); +var speakeasy = require('speakeasy'); +var ldapjs = require('ldapjs'); var PORT = 8090; var BASE_URL = 'http://localhost:' + PORT; @@ -19,14 +23,6 @@ describe('test the server', function() { var u2f, nedb; var transporter; var collection; - var ldap_client = { - bind: sinon.stub(), - search: sinon.stub(), - modify: sinon.stub(), - }; - var ldap = { - Change: sinon.spy() - } beforeEach(function(done) { var config = { @@ -34,8 +30,8 @@ describe('test the server', function() { totp_secret: 'totp_secret', ldap: { url: 'ldap://127.0.0.1:389', - user_search_base: 'ou=users,dc=example,dc=com', - user_search_filter: 'cn', + base_dn: 'ou=users,dc=example,dc=com', + user_name_attribute: 'cn', user: 'cn=admin,dc=example,dc=com', password: 'password', }, @@ -52,6 +48,19 @@ describe('test the server', function() { } }; + var ldap_client = { + bind: sinon.stub(), + search: sinon.stub(), + modify: sinon.stub(), + on: sinon.spy() + }; + var ldap = { + Change: sinon.spy(), + createClient: sinon.spy(function() { + return ldap_client; + }) + }; + u2f = {}; u2f.startRegistration = sinon.stub(); u2f.finishRegistration = sinon.stub(); @@ -68,15 +77,15 @@ describe('test the server', function() { return transporter;   }); - var search_doc = { + ldap_document = { object: { - mail: 'test_ok@example.com' + mail: 'test_ok@example.com', } }; var search_res = {}; search_res.on = sinon.spy(function(event, fn) { - if(event != 'error') fn(search_doc); + if(event != 'error') fn(ldap_document); }); ldap_client.bind.withArgs('cn=test_ok,ou=users,dc=example,dc=com', @@ -94,10 +103,12 @@ describe('test the server', function() { deps.u2f = u2f; deps.nedb = nedb; deps.nodemailer = nodemailer; - deps.ldap = ldap; + deps.ldapjs = ldap; deps.session = session; + deps.winston = winston; + deps.speakeasy = speakeasy; - _server = server.run(config, ldap_client, deps, function() { + _server = server.run(config, deps, function() { done(); }); }); @@ -352,7 +363,6 @@ describe('test the server', function() { return requests.failing_first_factor(j); }) .then(function(res) { - console.log('coucou'); assert.equal(res.statusCode, 401, 'first factor failed'); return requests.failing_first_factor(j); }) diff --git a/test/unitary/test_server_config.js b/test/unitary/test_server_config.js index 6f90f396..b5c26b5e 100644 --- a/test/unitary/test_server_config.js +++ b/test/unitary/test_server_config.js @@ -26,7 +26,12 @@ describe('test server configuration', function() { deps = {}; deps.nedb = require('nedb'); + deps.winston = sinon.spy(); deps.nodemailer = nodemailer; + deps.ldapjs = {}; + deps.ldapjs.createClient = sinon.spy(function() { + return { on: sinon.spy() }; + }); deps.session = sinon.spy(function() { return function(req, res, next) { next(); }; }); @@ -36,7 +41,9 @@ describe('test server configuration', function() { it('should set cookie scope to domain set in the config', function() { config.session = {}; config.session.domain = 'example.com'; - server.run(config, undefined, deps); + config.ldap = {}; + config.ldap.url = 'http://ldap'; + server.run(config, deps); assert(deps.session.calledOnce); assert.equal(deps.session.getCall(0).args[0].cookie.domain, 'example.com'); From e310478e6dd532d2d729629dadb80a08f14b9094 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 25 Mar 2017 15:28:57 +0100 Subject: [PATCH 3/6] Allow per user access control rules --- config.template.yml | 3 ++ src/lib/routes/first_factor.js | 13 ++++-- test/unitary/routes/test_first_factor.js | 54 +++++++++++++++++------- 3 files changed, 50 insertions(+), 20 deletions(-) diff --git a/config.template.yml b/config.template.yml index b84fb053..d05e0de6 100644 --- a/config.template.yml +++ b/config.template.yml @@ -50,6 +50,9 @@ access_control: - group: dev allowed_domains: - secret2.test.local + - user: harry + allowed_domains: + - secret1.test.local # Configuration of session cookies diff --git a/src/lib/routes/first_factor.js b/src/lib/routes/first_factor.js index 4ba9a97c..59464e6e 100644 --- a/src/lib/routes/first_factor.js +++ b/src/lib/routes/first_factor.js @@ -5,13 +5,17 @@ var exceptions = require('../exceptions'); var objectPath = require('object-path'); var Promise = require('bluebird'); -function get_allowed_domains(access_control, groups) { +function get_allowed_domains(access_control, username, groups) { var allowed_domains = []; for(var i = 0; i= 0) { + if('allowed_domains' in rule) { + if('group' in rule && groups.indexOf(rule['group']) >= 0) { + var domains = rule.allowed_domains; + allowed_domains = allowed_domains.concat(domains); + } + else if('user' in rule && username == rule['user']) { var domains = rule.allowed_domains; allowed_domains = allowed_domains.concat(domains); } @@ -58,7 +62,8 @@ function first_factor(req, res) { objectPath.set(req, 'session.auth_session.email', emails[0]); if(config.access_control) { - var allowed_domains = get_allowed_domains(config.access_control, groups); + var allowed_domains = get_allowed_domains(config.access_control, + username, groups); logger.debug('1st factor: allowed domains are %s', allowed_domains); objectPath.set(req, 'session.auth_session.allowed_domains', allowed_domains); diff --git a/test/unitary/routes/test_first_factor.js b/test/unitary/routes/test_first_factor.js index 4798f982..9f52bc92 100644 --- a/test/unitary/routes/test_first_factor.js +++ b/test/unitary/routes/test_first_factor.js @@ -74,23 +74,45 @@ describe('test the first factor validation route', function() { }); }); - it('should store the allowed domains in the auth session', function() { - config.access_control = []; - config.access_control.push({ - group: 'group1', - allowed_domains: ['domain1.example.com', 'domain2.example.com'] - }); - return new Promise(function(resolve, reject) { - res.send = sinon.spy(function(data) { - assert.deepEqual(['domain1.example.com', 'domain2.example.com'], - req.session.auth_session.allowed_domains); - assert.equal(204, res.status.getCall(0).args[0]); - resolve(); + describe('store the allowed domains in the auth session', function() { + it('should store the per group allowed domains', function() { + config.access_control = []; + config.access_control.push({ + group: 'group1', + allowed_domains: ['domain1.example.com', 'domain2.example.com'] + }); + return new Promise(function(resolve, reject) { + res.send = sinon.spy(function(data) { + assert.deepEqual(['domain1.example.com', 'domain2.example.com'], + req.session.auth_session.allowed_domains); + assert.equal(204, res.status.getCall(0).args[0]); + resolve(); + }); + ldap_interface_mock.bind.withArgs('username').returns(Promise.resolve()); + ldap_interface_mock.get_emails.returns(Promise.resolve(emails)); + ldap_interface_mock.get_groups.returns(Promise.resolve(groups)); + first_factor(req, res); + }); + }); + + it('should store the per group allowed domains', function() { + config.access_control = []; + config.access_control.push({ + user: 'username', + allowed_domains: ['domain1.example.com', 'domain2.example.com'] + }); + return new Promise(function(resolve, reject) { + res.send = sinon.spy(function(data) { + assert.deepEqual(['domain1.example.com', 'domain2.example.com'], + req.session.auth_session.allowed_domains); + assert.equal(204, res.status.getCall(0).args[0]); + resolve(); + }); + ldap_interface_mock.bind.withArgs('username').returns(Promise.resolve()); + ldap_interface_mock.get_emails.returns(Promise.resolve(emails)); + ldap_interface_mock.get_groups.returns(Promise.resolve(groups)); + first_factor(req, res); }); - ldap_interface_mock.bind.withArgs('username').returns(Promise.resolve()); - ldap_interface_mock.get_emails.returns(Promise.resolve(emails)); - ldap_interface_mock.get_groups.returns(Promise.resolve(groups)); - first_factor(req, res); }); }); From 38a4570b2403b89bd71a995cc4953dd87765d762 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 25 Mar 2017 15:38:27 +0100 Subject: [PATCH 4/6] Edit the README to add an access control section and update the user base --- README.md | 13 ++++++++++++- config.template.yml | 1 + 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 584b6f8c..03501cef 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,12 @@ accepted before getting to the login page: ![first-factor-page](https://raw.githubusercontent.com/clems4ever/authelia/master/images/first_factor.png) ### 1st factor: LDAP -An LDAP server has been deployed for you with the following credentials: **user/password**. +An LDAP server has been deployed for you with the following credentials: + +- **john/password** is in the admin group and has access to every subdomain. +- **bob/password** is in the dev group and has only access to *secret2.test.local* +- **harry/password** is not in a group but has access to *secret1.test.local* +as per the configuration file. Type them in the login page and validate. Then, the second factor page should have appeared as shown below. @@ -99,6 +104,12 @@ email address. For the sake of the example, the email is delivered in the file ./notifications/notification.txt. Paste the link in your browser and you should be able to reset the password. +### Access Control +With **Authelia**, you can define your own access control rules for restricting +the access to certain subdomains to your users. Those rules are defined in the +configuration file and are per-user or per-group. Check out the +*config.template.yml* to see how they are defined. + ## Documentation ### Configuration The configuration of the server is defined in the file diff --git a/config.template.yml b/config.template.yml index d05e0de6..d258a2a9 100644 --- a/config.template.yml +++ b/config.template.yml @@ -47,6 +47,7 @@ access_control: allowed_domains: - secret.test.local - secret1.test.local + - secret2.test.local - group: dev allowed_domains: - secret2.test.local From b403cfe2f8539681cb2cdb1cc2bf7f893c81ac70 Mon Sep 17 00:00:00 2001 From: Clement Michaud Date: Sat, 25 Mar 2017 18:38:14 +0100 Subject: [PATCH 5/6] Rework the configuration of the access control to allow default policy for certain domains --- config.template.yml | 24 ++-- example/nginx_conf/index.html | 79 ++++++++++- example/nginx_conf/nginx.conf | 4 +- example/nginx_conf/secret.html | 3 +- src/lib/access_control.js | 84 ++++++++++++ src/lib/routes/first_factor.js | 10 +- src/lib/routes/verify.js | 20 ++- src/lib/server.js | 3 + test/unitary/routes/test_first_factor.js | 37 +++--- test/unitary/routes/test_verify.js | 23 ++-- test/unitary/test_access_control.js | 160 +++++++++++++++++++++++ 11 files changed, 387 insertions(+), 60 deletions(-) create mode 100644 src/lib/access_control.js create mode 100644 test/unitary/test_access_control.js diff --git a/config.template.yml b/config.template.yml index d258a2a9..789e6108 100644 --- a/config.template.yml +++ b/config.template.yml @@ -43,17 +43,19 @@ ldap: # is allowed to everyone. # Otherwise, the default policy is denied for any user and any subdomain. access_control: - - group: admin - allowed_domains: - - secret.test.local - - secret1.test.local - - secret2.test.local - - group: dev - allowed_domains: - - secret2.test.local - - user: harry - allowed_domains: - - secret1.test.local + default: + - home.test.local + groups: + admin: + - '*.test.local' + dev: + - secret.test.local + - secret2.test.local + users: + harry: + - secret1.test.local + bob: + - '*.mail.test.local' # Configuration of session cookies diff --git a/example/nginx_conf/index.html b/example/nginx_conf/index.html index c59b1e20..6eb9a534 100644 --- a/example/nginx_conf/index.html +++ b/example/nginx_conf/index.html @@ -3,8 +3,81 @@ Home page - You need to log in to access the secret!

- But you can also access it from another domain or still another one.

- You can also log off by visiting the following link. +

Access the secret

+ You need to log in to access the secret!

+ Try to access it via one of the following links.
+ + + You can also log off by visiting the following link. + +

List of users

+ Here is the list of credentials you can log in with to test access control. + +
    +
  • john / password: belongs to admin and dev groups.
  • +
  • bob / password: belongs to dev group only.
  • +
  • harry / password: does not belong to any group.
  • +
+ +

Access control rules

+ +
    +
  • Default policy +
      +
    • home.test.local
    • +
    +
  • + +
  • Groups policy +
      +
    • admin +
        +
      • *.test.local
      • +
      +
    • +
    • dev +
        +
      • secret.test.local
      • +
      • secret2.test.local
      • +
      +
    • +
    +
  • + +
  • Users policy +
      +
    • harry +
        +
      • secret1.test.local
      • +
      +
    • +
    • bob +
        +
      • *.mail.test.local
      • +
      +
    • +
    +
  • +
+ diff --git a/example/nginx_conf/nginx.conf b/example/nginx_conf/nginx.conf index cfb8c62e..4cce9207 100644 --- a/example/nginx_conf/nginx.conf +++ b/example/nginx_conf/nginx.conf @@ -60,7 +60,9 @@ http { listen 443 ssl; root /usr/share/nginx/html; - server_name secret1.test.local secret2.test.local secret.test.local localhost; + server_name secret1.test.local secret2.test.local secret.test.local + home.test.local mx1.mail.test.local mx2.mail.test.local + localhost; ssl on; ssl_certificate /etc/ssl/server.crt; diff --git a/example/nginx_conf/secret.html b/example/nginx_conf/secret.html index b0f43b63..8b44155a 100644 --- a/example/nginx_conf/secret.html +++ b/example/nginx_conf/secret.html @@ -3,6 +3,7 @@ Secret - This is a very important secret! + This is a very important secret!
+ Go back to home page. diff --git a/src/lib/access_control.js b/src/lib/access_control.js new file mode 100644 index 00000000..e185eb7a --- /dev/null +++ b/src/lib/access_control.js @@ -0,0 +1,84 @@ + +module.exports = function(logger, acl_config) { + return { + builder: new AccessControlBuilder(logger, acl_config), + matcher: new AccessControlMatcher(logger) + }; +} + +var objectPath = require('object-path'); + +// *************** PER DOMAIN MATCHER *************** +function AccessControlMatcher(logger) { + this.logger = logger; +} + +AccessControlMatcher.prototype.is_domain_allowed = function(domain, allowed_domains) { + // Allow all matcher + if(allowed_domains.length == 1 && allowed_domains[0] == '*') return true; + + this.logger.debug('ACL: trying to match %s with %s', domain, + JSON.stringify(allowed_domains)); + for(var i = 0; i < allowed_domains.length; ++i) { + var allowed_domain = allowed_domains[i]; + if(allowed_domain.startsWith('*') && + domain.endsWith(allowed_domain.substr(1))) { + return true; + } + else if(domain == allowed_domain) { + return true; + } + } + return false; +} + + +// *************** MATCHER BUILDER *************** +function AccessControlBuilder(logger, acl_config) { + this.logger = logger; + this.config = acl_config; +} + +AccessControlBuilder.prototype.extract_per_group = function(groups) { + var allowed_domains = []; + var groups_policy = objectPath.get(this.config, 'groups'); + if(groups_policy) { + for(var i=0; i Date: Sat, 25 Mar 2017 18:42:48 +0100 Subject: [PATCH 6/6] Edit README to make the user add more subdomains in /etc/hosts for testing the example locally --- README.md | 29 ++++++++++++++++++++--------- config.template.yml | 14 +++++++++----- 2 files changed, 29 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 03501cef..52d8e0a3 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,9 @@ Add the following lines to your /etc/hosts to simulate multiple subdomains 127.0.0.1 secret.test.local 127.0.0.1 secret1.test.local 127.0.0.1 secret2.test.local + 127.0.0.1 home.test.local + 127.0.0.1 mx1.mail.test.local + 127.0.0.1 mx2.mail.test.local 127.0.0.1 auth.test.local Then, type the following command to build and deploy the services: @@ -48,20 +51,28 @@ Then, type the following command to build and deploy the services: docker-compose up -d After few seconds the services should be running and you should be able to visit -[https://secret.test.local:8080/](https://secret.test.local:8080/). +[https://home.test.local:8080/](https://home.test.local:8080/). Normally, a self-signed certificate exception should appear, it has to be accepted before getting to the login page: ![first-factor-page](https://raw.githubusercontent.com/clems4ever/authelia/master/images/first_factor.png) -### 1st factor: LDAP -An LDAP server has been deployed for you with the following credentials: +### 1st factor: LDAP and ACL +An LDAP server has been deployed for you with the following credentials and +access control list: -- **john/password** is in the admin group and has access to every subdomain. -- **bob/password** is in the dev group and has only access to *secret2.test.local* -- **harry/password** is not in a group but has access to *secret1.test.local* -as per the configuration file. +- **john / password** is in the admin group and has access to the secret from +any subdomain. +- **bob / password** is in the dev group and has access to the secret from + - [secret.test.local](https://secret.test.local:8080/secret.html) + - [secret2.test.local](https://secret2.test.local:8080/secret.html) + - [home.test.local](https://home.test.local:8080/secret.html) + - [\*.mail.test.local](https://mx1.mail.test.local:8080/secret.html) +- **harry / password** is not in a group but has rules giving him has access to + the secret from + - [secret1.test.local](https://secret1.test.local:8080/secret.html) + - [home.test.local](https://home.test.local:8080/secret.html) Type them in the login page and validate. Then, the second factor page should have appeared as shown below. @@ -107,8 +118,8 @@ Paste the link in your browser and you should be able to reset the password. ### Access Control With **Authelia**, you can define your own access control rules for restricting the access to certain subdomains to your users. Those rules are defined in the -configuration file and are per-user or per-group. Check out the -*config.template.yml* to see how they are defined. +configuration file and can be either default, per-user or per-group policies. +Check out the *config.template.yml* to see how they are defined. ## Documentation ### Configuration diff --git a/config.template.yml b/config.template.yml index 789e6108..a70c8d3c 100644 --- a/config.template.yml +++ b/config.template.yml @@ -36,12 +36,16 @@ ldap: # Access Control # -# Access control is a set of rules where you can specify a group-based -# subdomain restrictions. +# Access control is a set of rules you can use to restrict the user access. +# Default (anyone), per-user or per-group rules can be defined. # -# If access_control is not defined, ACL rules are disabled and default policy -# is allowed to everyone. -# Otherwise, the default policy is denied for any user and any subdomain. +# If 'access_control' is not defined, ACL rules are disabled and default policy +# is applied, i.e., access is allowed to anyone. Otherwise restrictions follow +# the rules defined below. +# If no rule is provided, all domains are denied. +# +# '*' means 'any' subdomains and matches any string. It must stand at the +# beginning of the pattern. access_control: default: - home.test.local