diff --git a/package-lock.json b/package-lock.json index 40cba11e..732200ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2997,6 +2997,14 @@ "resolved": "https://registry.npmjs.org/backo2/-/backo2-1.0.2.tgz", "integrity": "sha1-MasayLEpNjRj41s+u2n038+6eUc=" }, + "backoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/backoff/-/backoff-2.5.0.tgz", + "integrity": "sha1-9hbtqdPktmuMp/ynn2lXIsX44m8=", + "requires": { + "precond": "0.2" + } + }, "balanced-match": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", @@ -3302,6 +3310,17 @@ "resolved": "https://registry.npmjs.org/buffer-writer/-/buffer-writer-2.0.0.tgz", "integrity": "sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==" }, + "bunyan": { + "version": "1.8.12", + "resolved": "https://registry.npmjs.org/bunyan/-/bunyan-1.8.12.tgz", + "integrity": "sha1-8VDw9nSKvdcq6uhPBEA74u8RN5c=", + "requires": { + "dtrace-provider": "~0.8", + "moment": "^2.10.6", + "mv": "~2", + "safe-json-stringify": "~1" + } + }, "busboy": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/busboy/-/busboy-0.3.1.tgz", @@ -4463,6 +4482,23 @@ } } }, + "dtrace-provider": { + "version": "0.8.8", + "resolved": "https://registry.npmjs.org/dtrace-provider/-/dtrace-provider-0.8.8.tgz", + "integrity": "sha512-b7Z7cNtHPhH9EJhNNbbeqTcXB8LGFFZhq1PGgEvpeHlzd36bhbdTWoE/Ba/YguqpBSlAPKnARWhVlhunCMwfxg==", + "optional": true, + "requires": { + "nan": "^2.14.0" + }, + "dependencies": { + "nan": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", + "integrity": "sha512-INOFj37C7k3AfaNTtX8RhsTw7qRy7eLET14cROi9+5HAVbbHuIWUHEauBv5qT4Av2tWasiTY1Jw6puUNqRJXQg==", + "optional": true + } + } + }, "duplexer3": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/duplexer3/-/duplexer3-0.1.4.tgz", @@ -7769,6 +7805,45 @@ "resolved": "https://registry.npmjs.org/lcov-parse/-/lcov-parse-0.0.10.tgz", "integrity": "sha1-GwuP+ayceIklBYK3C3ExXZ2m2aM=" }, + "ldap-filter": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/ldap-filter/-/ldap-filter-0.2.2.tgz", + "integrity": "sha1-8rhCvguG2jNSeYUFsx68rlkNd9A=", + "requires": { + "assert-plus": "0.1.5" + }, + "dependencies": { + "assert-plus": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-0.1.5.tgz", + "integrity": "sha1-7nQAlBMALYTOxyGcasgRgS5yMWA=" + } + } + }, + "ldapjs": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/ldapjs/-/ldapjs-1.0.2.tgz", + "integrity": "sha1-VE/3Ayt7g8aPBwEyjZKXqmlDQPk=", + "requires": { + "asn1": "0.2.3", + "assert-plus": "^1.0.0", + "backoff": "^2.5.0", + "bunyan": "^1.8.3", + "dashdash": "^1.14.0", + "dtrace-provider": "~0.8", + "ldap-filter": "0.2.2", + "once": "^1.4.0", + "vasync": "^1.6.4", + "verror": "^1.8.1" + }, + "dependencies": { + "asn1": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.3.tgz", + "integrity": "sha1-2sh4dxPJlmhJ/IGAd36+nB3fO4Y=" + } + } + }, "levn": { "version": "0.3.0", "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", @@ -9043,6 +9118,41 @@ "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==", "dev": true }, + "mv": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/mv/-/mv-2.1.1.tgz", + "integrity": "sha1-rmzg1vbV4KT32JN5jQPB6pVZtqI=", + "optional": true, + "requires": { + "mkdirp": "~0.5.1", + "ncp": "~2.0.0", + "rimraf": "~2.4.0" + }, + "dependencies": { + "glob": { + "version": "6.0.4", + "resolved": "https://registry.npmjs.org/glob/-/glob-6.0.4.tgz", + "integrity": "sha1-DwiGD2oVUSey+t1PnOJLGqtuTSI=", + "optional": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "2 || 3", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "rimraf": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.4.5.tgz", + "integrity": "sha1-7nEM5dk6j9uFb7Xqj/Di11k0sto=", + "optional": true, + "requires": { + "glob": "^6.0.1" + } + } + } + }, "nan": { "version": "2.14.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.0.tgz", @@ -9076,6 +9186,12 @@ "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", "dev": true }, + "ncp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ncp/-/ncp-2.0.0.tgz", + "integrity": "sha1-GVoh1sRuNh0vsSgbo4uR6d9727M=", + "optional": true + }, "needle": { "version": "2.4.0", "resolved": "https://registry.npmjs.org/needle/-/needle-2.4.0.tgz", @@ -9989,6 +10105,11 @@ "xtend": "^4.0.0" } }, + "precond": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/precond/-/precond-0.2.3.tgz", + "integrity": "sha1-qpWRvKokkj8eD0hJ0kD0fvwQdaw=" + }, "prelude-ls": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", @@ -10587,6 +10708,12 @@ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.0.tgz", "integrity": "sha512-fZEwUGbVl7kouZs1jCdMLdt95hdIv0ZeHg6L7qPeciMZhZ+/gdesW4wgTARkrFWEpspjEATAzUGPG8N2jJiwbg==" }, + "safe-json-stringify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/safe-json-stringify/-/safe-json-stringify-1.2.0.tgz", + "integrity": "sha512-gH8eh2nZudPQO6TytOvbxnuhYBOvDBBLW52tz5q6X58lJcd/tkmqFR+5Z9adS8aJtURSXWThWy/xJtJwixErvg==", + "optional": true + }, "safe-regex": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", @@ -11954,6 +12081,29 @@ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", "integrity": "sha1-IpnwLG3tMNSllhsLn3RSShj2NPw=" }, + "vasync": { + "version": "1.6.4", + "resolved": "https://registry.npmjs.org/vasync/-/vasync-1.6.4.tgz", + "integrity": "sha1-3+k2Fq0OeugBszKp2Iv8XNyOHR8=", + "requires": { + "verror": "1.6.0" + }, + "dependencies": { + "extsprintf": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.2.0.tgz", + "integrity": "sha1-WtlGwi9bMrp/jNdCZxHG6KP8JSk=" + }, + "verror": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.6.0.tgz", + "integrity": "sha1-fROyex+swuLakEBetepuW90lLqU=", + "requires": { + "extsprintf": "1.2.0" + } + } + } + }, "verror": { "version": "1.10.0", "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", diff --git a/package.json b/package.json index 7c51f2dd..60e7cd79 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "graphql-upload": "8.1.0", "intersect": "1.0.1", "jsonwebtoken": "8.5.1", + "ldapjs": "1.0.2", "lodash": "4.17.15", "lru-cache": "5.1.1", "mime": "2.4.4", diff --git a/spec/LdapAuth.spec.js b/spec/LdapAuth.spec.js new file mode 100644 index 00000000..321d8de1 --- /dev/null +++ b/spec/LdapAuth.spec.js @@ -0,0 +1,138 @@ +const ldap = require('../lib/Adapters/Auth/ldap'); +const mockLdapServer = require('./MockLdapServer'); +const port = 12345; + +it('Should fail with missing options', done => { + ldap + .validateAuthData({ id: 'testuser', password: 'testpw' }) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP auth configuration missing'); + done(); + }); +}); + +it('Should return a resolved promise when validating the app id', done => { + ldap + .validateAppId() + .then(done) + .catch(done.fail); +}); + +it('Should succeed with right credentials', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done) + .catch(done.fail) + .finally(() => server.close()); + }); +}); + +it('Should fail with wrong credentials', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + }; + ldap + .validateAuthData({ id: 'testuser', password: 'wrong!' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP: Wrong username or password'); + done(); + }) + .finally(() => server.close()); + }); +}); + +it('Should succeed if user is in given group', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done) + .catch(done.fail) + .finally(() => server.close()); + }); +}); + +it('Should fail if user is not in given group', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'groupTheUserIsNotIn', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP: User not in group'); + done(); + }) + .finally(() => server.close()); + }); +}); + +it('Should fail if the LDAP server does not allow searching inside the provided suffix', done => { + mockLdapServer(port, 'uid=testuser, o=example').then(server => { + const options = { + suffix: 'o=invalid', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP group search failed'); + done(); + }) + .finally(() => server.close()); + }); +}); + +it('Should fail if the LDAP server encounters an error while searching', done => { + mockLdapServer(port, 'uid=testuser, o=example', true).then(server => { + const options = { + suffix: 'o=example', + url: `ldap://localhost:${port}`, + dn: 'uid={{id}}, o=example', + groupCn: 'powerusers', + groupFilter: + '(&(uniqueMember=uid={{id}}, o=example)(objectClass=groupOfUniqueNames))', + }; + + ldap + .validateAuthData({ id: 'testuser', password: 'secret' }, options) + .then(done.fail) + .catch(err => { + jequal(err.message, 'LDAP group search failed'); + done(); + }) + .finally(() => server.close()); + }); +}); diff --git a/spec/MockLdapServer.js b/spec/MockLdapServer.js new file mode 100644 index 00000000..aa8a7673 --- /dev/null +++ b/spec/MockLdapServer.js @@ -0,0 +1,48 @@ +const ldapjs = require('ldapjs'); + +function newServer(port, dn, provokeSearchError = false) { + const server = ldapjs.createServer(); + + server.bind('o=example', function(req, res, next) { + if (req.dn.toString() !== dn || req.credentials !== 'secret') + return next(new ldapjs.InvalidCredentialsError()); + res.end(); + return next(); + }); + + server.search('o=example', function(req, res, next) { + if (provokeSearchError) { + res.end(ldapjs.LDAP_SIZE_LIMIT_EXCEEDED); + return next(new ldapjs.NoSuchObjectError('fake error')); + } + const obj = { + dn: req.dn.toString(), + attributes: { + objectclass: ['organization', 'top'], + o: 'example', + }, + }; + + const group = { + dn: req.dn.toString(), + attributes: { + objectClass: ['groupOfUniqueNames', 'top'], + uniqueMember: ['uid=testuser, o=example'], + cn: 'powerusers', + ou: 'powerusers', + }, + }; + + if (req.filter.matches(obj.attributes)) { + res.send(obj); + } + + if (req.filter.matches(group.attributes)) { + res.send(group); + } + res.end(); + }); + return new Promise(resolve => server.listen(port, () => resolve(server))); +} + +module.exports = newServer; diff --git a/src/Adapters/Auth/index.js b/src/Adapters/Auth/index.js index 07c956ce..e5e8c955 100755 --- a/src/Adapters/Auth/index.js +++ b/src/Adapters/Auth/index.js @@ -23,6 +23,7 @@ const weibo = require('./weibo'); const oauth2 = require('./oauth2'); const phantauth = require('./phantauth'); const microsoft = require('./microsoft'); +const ldap = require('./ldap'); const anonymous = { validateAuthData: () => { @@ -57,6 +58,7 @@ const providers = { weibo, phantauth, microsoft, + ldap, }; function authDataValidator(adapter, appIds, options) { diff --git a/src/Adapters/Auth/ldap.js b/src/Adapters/Auth/ldap.js new file mode 100644 index 00000000..5b3c3a05 --- /dev/null +++ b/src/Adapters/Auth/ldap.js @@ -0,0 +1,113 @@ +const ldapjs = require('ldapjs'); +const Parse = require('parse/node').Parse; + +function validateAuthData(authData, options) { + if (!optionsAreValid(options)) { + return new Promise((_, reject) => { + reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP auth configuration missing' + ) + ); + }); + } + + const client = ldapjs.createClient({ url: options.url }); + const userCn = + typeof options.dn === 'string' + ? options.dn.replace('{{id}}', authData.id) + : `uid=${authData.id},${options.suffix}`; + + return new Promise((resolve, reject) => { + client.bind(userCn, authData.password, err => { + if (err) { + client.destroy(err); + return reject( + new Parse.Error( + Parse.Error.OBJECT_NOT_FOUND, + 'LDAP: Wrong username or password' + ) + ); + } + + if ( + typeof options.groupCn === 'string' && + typeof options.groupFilter === 'string' + ) { + searchForGroup(client, options, authData.id, resolve, reject); + } else { + client.unbind(); + client.destroy(); + resolve(); + } + }); + }); +} + +function optionsAreValid(options) { + return ( + typeof options === 'object' && + typeof options.suffix === 'string' && + typeof options.url === 'string' && + options.url.startsWith('ldap://') + ); +} + +function searchForGroup(client, options, id, resolve, reject) { + const filter = options.groupFilter.replace(/{{id}}/gi, id); + const opts = { + scope: 'sub', + filter: filter, + }; + let found = false; + client.search(options.suffix, opts, (searchError, res) => { + if (searchError) { + client.unbind(); + client.destroy(); + return reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP group search failed' + ) + ); + } + res.on('searchEntry', entry => { + if (entry.object.cn === options.groupCn) { + found = true; + client.unbind(); + client.destroy(); + return resolve(); + } + }); + res.on('end', () => { + if (!found) { + client.unbind(); + client.destroy(); + return reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP: User not in group' + ) + ); + } + }); + res.on('error', () => { + return reject( + new Parse.Error( + Parse.Error.INTERNAL_SERVER_ERROR, + 'LDAP group search failed' + ) + ); + }); + }); +} + +function validateAppId() { + return Promise.resolve(); +} + +module.exports = { + validateAppId, + validateAuthData, +};