diff --git a/packages/xo-server-auth-ldap/.editorconfig b/packages/xo-server-auth-ldap/.editorconfig new file mode 100644 index 000000000..da21ef4c5 --- /dev/null +++ b/packages/xo-server-auth-ldap/.editorconfig @@ -0,0 +1,65 @@ +# http://EditorConfig.org +# +# Julien Fontanet's configuration +# https://gist.github.com/julien-f/8096213 + +# Top-most EditorConfig file. +root = true + +# Common config. +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespaces = true + +# CoffeeScript +# +# https://github.com/polarmobile/coffeescript-style-guide/blob/master/README.md +[*.{,lit}coffee] +indent_size = 2 +indent_style = space + +# Markdown +[*.{md,mdwn,mdown,markdown}] +indent_size = 4 +indent_style = space + +# Package.json +# +# This indentation style is the one used by npm. +[/package.json] +indent_size = 2 +indent_style = space + +# Jade +[*.jade] +indent_size = 2 +indent_style = space + +# JavaScript +# +# Two spaces seems to be the standard most common style, at least in +# Node.js (http://nodeguide.com/style.html#tabs-vs-spaces). +[*.js] +indent_size = 2 +indent_style = space + +# Less +[*.less] +indent_size = 2 +indent_style = space + +# Sass +# +# Style used for http://libsass.com +[*.s[ac]ss] +indent_size = 2 +indent_style = space + +# YAML +# +# Only spaces are allowed. +[*.yaml] +indent_size = 2 +indent_style = space diff --git a/packages/xo-server-auth-ldap/.gitignore b/packages/xo-server-auth-ldap/.gitignore new file mode 100644 index 000000000..9e41c869a --- /dev/null +++ b/packages/xo-server-auth-ldap/.gitignore @@ -0,0 +1,8 @@ +/dist/ +/ldap.cache.conf +/node_modules/ + +npm-debug.log +npm-debug.log.* +pnpm-debug.log +pnpm-debug.log.* diff --git a/packages/xo-server-auth-ldap/.npmignore b/packages/xo-server-auth-ldap/.npmignore new file mode 100644 index 000000000..c31ee82cb --- /dev/null +++ b/packages/xo-server-auth-ldap/.npmignore @@ -0,0 +1,10 @@ +/examples/ +example.js +example.js.map +*.example.js +*.example.js.map + +/test/ +/tests/ +*.spec.js +*.spec.js.map diff --git a/packages/xo-server-auth-ldap/.travis.yml b/packages/xo-server-auth-ldap/.travis.yml new file mode 100644 index 000000000..ae52e87e6 --- /dev/null +++ b/packages/xo-server-auth-ldap/.travis.yml @@ -0,0 +1,9 @@ +language: node_js +node_js: + - 'stable' + - '6' + - '4' + +# Use containers. +# http://docs.travis-ci.com/user/workers/container-based-infrastructure/ +sudo: false diff --git a/packages/xo-server-auth-ldap/README.md b/packages/xo-server-auth-ldap/README.md new file mode 100644 index 000000000..fd27082dc --- /dev/null +++ b/packages/xo-server-auth-ldap/README.md @@ -0,0 +1,82 @@ +# xo-server-auth-ldap [![Build Status](https://travis-ci.org/vatesfr/xo-server-auth-ldap.png?branch=master)](https://travis-ci.org/vatesfr/xo-server-auth-ldap) + +> LDAP authentication plugin for XO-Server + +This plugin allows LDAP users to authenticate to Xen-Orchestra. + +The first time a user signs in, XO will create a new XO user with the +same identifier. + +## Install + +Installation of the [npm package](https://npmjs.org/package/xo-server-auth-ldap): + +``` +> npm install --global xo-server-auth-ldap +``` + +## Usage + +Like all other xo-server plugins, it can be configured directly via +the web iterface, see [the plugin documentation](https://xen-orchestra.com/docs/plugins.html). + +If you have issues, you can use the provided CLI to gather more +information: + +``` +> xo-server-auth-ldap +? uri ldap://ldap.company.net +? fill optional certificateAuthorities? No +? fill optional checkCertificate? No +? fill optional bind? No +? base ou=people,dc=company,dc=net +? fill optional filter? No +configuration saved in ./ldap.cache.conf +? Username john.smith +? Password ***** +searching for entries... +0 entries found +could not authenticate john.smith +``` + +## Algorithm + +1. If `bind` is defined, attempt to bind using this user. +2. Searches for the user in the directory starting from the `base` + with the defined `filter`. +3. If found, a bind is attempted using the distinguished name of this + user and the provided password. + +## Development + +``` +# Install dependencies +> npm install + +# Run the tests +> npm test + +# Continuously compile +> npm run dev + +# Continuously run the tests +> npm run dev-test + +# Build for production (automatically called by npm install) +> npm run build +``` + +## Contributions + +Contributions are *very* welcomed, either on the documentation or on +the code. + +You may: + +- report any [issue](https://github.com/vatesfr/xo-server-auth-ldap/issues) + you've encountered; +- fork and create a pull request. + +## License + +AGPL3 © [Vates SAS](http://vates.fr) diff --git a/packages/xo-server-auth-ldap/package.json b/packages/xo-server-auth-ldap/package.json new file mode 100644 index 000000000..7c80c29c2 --- /dev/null +++ b/packages/xo-server-auth-ldap/package.json @@ -0,0 +1,82 @@ +{ + "name": "xo-server-auth-ldap", + "version": "0.6.0", + "license": "AGPL-3.0", + "description": "LDAP authentication plugin for XO-Server", + "keywords": [ + "ldap", + "orchestra", + "plugin", + "xen", + "xen-orchestra", + "xo-server" + ], + "homepage": "https://github.com/vatesfr/xo-server-auth-ldap", + "bugs": "https://github.com/vatesfr/xo-server-auth-ldap/issues", + "repository": { + "type": "git", + "url": "https://github.com/vatesfr/xo-server-auth-ldap" + }, + "author": { + "name": "Julien Fontanet", + "email": "julien.fontanet@vates.fr" + }, + "preferGlobal": false, + "main": "dist/", + "bin": { + "xo-server-auth-ldap": "dist/test-cli.js" + }, + "files": [ + "dist/" + ], + "engines": { + "node": ">=4" + }, + "dependencies": { + "babel-runtime": "^6.3.19", + "event-to-promise": "^0.7.0", + "exec-promise": "^0.6.1", + "fs-promise": "^1.0.0", + "inquirer": "^2.0.0", + "ldapjs": "^1.0.0" + }, + "devDependencies": { + "babel-cli": "^6.3.17", + "babel-eslint": "^7.0.0", + "babel-plugin-transform-runtime": "^6.3.13", + "babel-preset-latest": "^6.16.0", + "babel-preset-stage-0": "^6.3.13", + "cross-env": "^3.1.3", + "dependency-check": "^2.5.1", + "husky": "^0.12.0", + "rimraf": "^2.5.4", + "standard": "^8.0.0" + }, + "scripts": { + "build": "cross-env NODE_ENV=production babel --source-maps --out-dir=dist/ src/", + "clean": "rimraf dist/", + "commit-msg": "npm test", + "depcheck": "dependency-check ./package.json", + "dev": "cross-env NODE_ENV=development babel --watch --source-maps --out-dir=dist/ src/", + "lint": "standard", + "posttest": "npm run lint && npm run depcheck", + "prebuild": "npm run clean", + "predev": "npm run clean", + "prepublish": "npm run build" + }, + "babel": { + "plugins": [ + "transform-runtime" + ], + "presets": [ + "latest", + "stage-0" + ] + }, + "standard": { + "ignore": [ + "dist" + ], + "parser": "babel-eslint" + } +} diff --git a/packages/xo-server-auth-ldap/src/index.js b/packages/xo-server-auth-ldap/src/index.js new file mode 100644 index 000000000..cc97601e1 --- /dev/null +++ b/packages/xo-server-auth-ldap/src/index.js @@ -0,0 +1,262 @@ +/* eslint no-throw-literal: 0 */ + +import eventToPromise from 'event-to-promise' +import { createClient } from 'ldapjs' +import { escape } from 'ldapjs/lib/filters/escape' +import { readFile } from 'fs-promise' + +// =================================================================== + +const bind = (fn, thisArg) => function () { + return fn.apply(thisArg, arguments) +} + +const noop = () => {} + +export const promisify = (fn, thisArg) => function () { + const { length } = arguments + const args = new Array(length + 1) + for (let i = 0; i < length; ++i) { + args[i] = arguments[i] + } + + return new Promise((resolve, reject) => { + args[length] = (error, result) => error + ? reject(error) + : resolve(result) + + fn.apply(thisArg || this, args) + }) +} + +// ------------------------------------------------------------------- + +const VAR_RE = /\{\{([^}]+)\}\}/g +const evalFilter = (filter, vars) => filter.replace(VAR_RE, (_, name) => { + const value = vars[name] + + if (value === undefined) { + throw new Error('invalid variable: ' + name) + } + + return escape(value) +}) + +export const configurationSchema = { + type: 'object', + properties: { + uri: { + description: 'URI of the LDAP server.', + type: 'string' + }, + certificateAuthorities: { + description: ` +Paths to CA certificates to use when connecting to SSL-secured LDAP servers. + +If not specified, it will use a default set of well-known CAs. +`.trim(), + type: 'array', + items: { + type: 'string' + } + }, + checkCertificate: { + description: 'Enforce the validity of the server\'s certificates. You can disable it when connecting to servers that use a self-signed certificate.', + type: 'boolean', + default: true + }, + bind: { + description: 'Credentials to use before looking for the user record.', + type: 'object', + properties: { + dn: { + description: ` +Distinguished name of the user permitted to search the LDAP directory for the user to authenticate. + +For Microsoft Active Directory, it can also be \`@\`. +`.trim(), + type: 'string' + }, + password: { + description: 'Password of the user permitted of search the LDAP directory.', + type: 'string' + } + }, + required: ['dn', 'password'] + }, + base: { + description: 'The base is the part of the description tree where the users are looked for.', + type: 'string' + }, + filter: { + description: ` +Filter used to find the user. + +For Microsoft Active Directory, you can try one of the following filters: + +- \`(cn={{name}})\` +- \`(sAMAccountName={{name}})\` +- \`(sAMAccountName={{name}}@)\` +- \`(userPrincipalName={{name}})\` +`.trim(), + type: 'string', + default: '(uid={{name}})' + } + }, + required: ['uri', 'base'] +} + +export const testSchema = { + type: 'object', + properties: { + username: { + description: 'LDAP username', + type: 'string' + }, + password: { + description: 'LDAP password', + type: 'string' + } + }, + required: ['username', 'password'] +} + +// =================================================================== + +class AuthLdap { + constructor (xo) { + this._xo = xo + + this._authenticate = bind(this._authenticate, this) + } + + async configure (conf) { + const clientOpts = this._clientOpts = { + url: conf.uri, + maxConnections: 5, + tlsOptions: {} + } + + { + const { + bind, + checkCertificate = true, + certificateAuthorities + } = conf + + if (bind) { + clientOpts.bindDN = bind.dn + clientOpts.bindCredentials = bind.password + } + + const {tlsOptions} = clientOpts + + tlsOptions.rejectUnauthorized = checkCertificate + if (certificateAuthorities) { + tlsOptions.ca = await Promise.all( + certificateAuthorities.map(path => readFile(path)) + ) + } + } + + const { + bind: credentials, + base: searchBase, + filter: searchFilter = '(uid={{name}})' + } = conf + + this._credentials = credentials + this._searchBase = searchBase + this._searchFilter = searchFilter + } + + load () { + this._xo.registerAuthenticationProvider(this._authenticate) + } + + unload () { + this._xo.unregisterAuthenticationProvider(this._authenticate) + } + + test ({ username, password }) { + return this._authenticate({ + username, + password + }).then(result => { + if (result === null) { + throw new Error('could not authenticate user') + } + }) + } + + async _authenticate ({ username, password }, logger = noop) { + if (username === undefined || password === undefined) { + logger('require `username` and `password` to authenticate!') + + return null + } + + const client = createClient(this._clientOpts) + + try { + // Promisify some methods. + const bind = promisify(client.bind, client) + const search = promisify(client.search, client) + + // Bind if necessary. + { + const {_credentials: credentials} = this + if (credentials) { + logger(`attempting to bind with as ${credentials.dn}...`) + await bind(credentials.dn, credentials.password) + logger(`successfully bound as ${credentials.dn}`) + } + } + + // Search for the user. + const entries = [] + { + logger('searching for entries...') + const response = await search(this._searchBase, { + scope: 'sub', + filter: evalFilter(this._searchFilter, { + name: username + }) + }) + + response.on('searchEntry', entry => { + logger('.') + entries.push(entry.json) + }) + + const {status} = await eventToPromise(response, 'end') + if (status) { + throw new Error('unexpected search response status: ' + status) + } + + logger(`${entries.length} entries found`) + } + + // Try to find an entry which can be bind with the given password. + for (const entry of entries) { + try { + logger(`attempting to bind as ${entry.objectName}`) + await bind(entry.objectName, password) + logger(`successfully bound as ${entry.objectName} => ${username} authenticated`) + return { username } + } catch (error) { + logger(`failed to bind as ${entry.objectName}: ${error.message}`) + } + } + + logger(`could not authenticate ${username}`) + return null + } finally { + client.unbind() + } + } +} + +// =================================================================== + +export default ({xo}) => new AuthLdap(xo) diff --git a/packages/xo-server-auth-ldap/src/prompt-schema.js b/packages/xo-server-auth-ldap/src/prompt-schema.js new file mode 100644 index 000000000..6c7e29560 --- /dev/null +++ b/packages/xo-server-auth-ldap/src/prompt-schema.js @@ -0,0 +1,220 @@ +import { prompt } from 'inquirer' + +// =================================================================== + +const forArray = (array, iteratee) => { + for (let i = 0, n = array.length; i < n; ++i) { + iteratee(array[i], i, array) + } +} + +const { hasOwnProperty } = Object.prototype +const forOwn = (object, iteratee) => { + for (const key in object) { + if (hasOwnProperty.call(object, key)) { + iteratee(object[key], key, object) + } + } +} + +// ------------------------------------------------------------------- + +const _makeAsyncIterator = iterator => (promises, cb) => { + let mainPromise = Promise.resolve() + + iterator(promises, (promise, key) => { + mainPromise = mainPromise + + // Waits the current promise. + .then(() => promise) + + // Executes the callback. + .then(value => cb(value, key)) + }) + + return mainPromise +} + +const forOwnAsync = _makeAsyncIterator(forOwn) + +// ------------------------------------------------------------------- + +const _isNaN = ( + Number.isNaN || + (value => value !== value) // eslint-disable-line no-self-compare +) + +const isNumber = value => !_isNaN(value) && typeof value === 'number' + +const isInteger = ( + Number.isInteger || + (value => ( + isNumber(value) && + value > -Infinity && value < Infinity && + Math.floor(value) === value + )) +) + +// =================================================================== + +const EMPTY_OBJECT = Object.freeze({ __proto__: null }) + +const _extractValue = ({ value }) => value + +export const confirm = (message, { + default: defaultValue = null +} = EMPTY_OBJECT) => prompt({ + default: defaultValue, + message, + name: 'value', + type: 'confirm' +}).then(_extractValue) + +export const input = (message, { + default: defaultValue = null, + filter = undefined, + validate = undefined +} = EMPTY_OBJECT) => prompt({ + default: defaultValue, + message, + name: 'value', + type: 'input', + validate +}).then(_extractValue) + +export const list = (message, choices, { + default: defaultValue = null +} = EMPTY_OBJECT) => prompt({ + default: defaultValue, + choices, + message, + name: 'value', + type: 'list' +}).then(_extractValue) + +export const password = (message, { + default: defaultValue = null, + filter = undefined, + validate = undefined +} = EMPTY_OBJECT) => prompt({ + default: defaultValue, + message, + name: 'value', + type: 'password', + validate +}).then(_extractValue) + +// =================================================================== + +const promptByType = { + __proto__: null, + + array: async (schema, defaultValue, path) => { + const items = [] + if (defaultValue == null) { + defaultValue = items + } + + let i = 0 + + const itemSchema = schema.items + const promptItem = async () => { + items[i] = await promptGeneric( + itemSchema, + defaultValue[i], + path + ? `${path} [${i}]` + : `[${i}]` + ) + + ++i + } + + let n = schema.minItems || 0 + while (i < n) { // eslint-disable-line no-unmodified-loop-condition + await promptItem() + } + + n = schema.maxItems || Infinity + while ( + i < n && // eslint-disable-line no-unmodified-loop-condition + await confirm('additional item?', { + default: false + }) + ) { + await promptItem() + } + + return items + }, + + boolean: (schema, defaultValue, path) => confirm(path, { + default: defaultValue || schema.default + }), + + enum: (schema, defaultValue, path) => list(path, schema.enum, { + defaultValue: defaultValue || schema.defaultValue + }), + + integer: (schema, defaultValue, path) => input(path, { + default: defaultValue || schema.default, + filter: input => +input, + validate: input => isInteger(+input) + }), + + number: (schema, defaultValue, path) => input(path, { + default: defaultValue || schema.default, + filter: input => +input, + validate: input => isNumber(+input) + }), + + object: async (schema, defaultValue, path) => { + const value = {} + + const required = {} + schema.required && forArray(schema.required, name => { + required[name] = true + }) + + const promptProperty = async (schema, name) => { + const subpath = path + ? `${path} > ${schema.title || name}` + : schema.title || name + + if ( + required[name] || + await confirm(`fill optional ${subpath}?`, { + default: false + }) + ) { + value[name] = await promptGeneric( + schema, + defaultValue && defaultValue[name], + subpath + ) + } + } + + await forOwnAsync(schema.properties || {}, promptProperty) + + return value + }, + + string: (schema, defaultValue, path) => input(path, { + default: defaultValue || schema.default + }) +} + +export default function promptGeneric (schema, defaultValue, path) { + const type = schema.enum + ? 'enum' + : schema.type + + const prompt = promptByType[type.toLowerCase()] + if (!prompt) { + throw new Error(`unsupported type: ${type}`) + } + + return prompt(schema, defaultValue, path) +} + diff --git a/packages/xo-server-auth-ldap/src/test-cli.js b/packages/xo-server-auth-ldap/src/test-cli.js new file mode 100755 index 000000000..0a602eefa --- /dev/null +++ b/packages/xo-server-auth-ldap/src/test-cli.js @@ -0,0 +1,47 @@ +#!/usr/bin/env node + +import execPromise from 'exec-promise' +import { readFile, writeFile } from 'fs-promise' + +import promptSchema, { + input, + password +} from './prompt-schema' +import createPlugin, { + configurationSchema +} from './' + +// =================================================================== + +const CACHE_FILE = './ldap.cache.conf' + +// ------------------------------------------------------------------- + +execPromise(async args => { + const config = await promptSchema( + configurationSchema, + await readFile(CACHE_FILE, 'utf-8').then( + JSON.parse, + () => ({}) + ) + ) + await writeFile(CACHE_FILE, JSON.stringify(config, null, 2)).then( + () => { + console.log('configuration saved in %s', CACHE_FILE) + }, + error => { + console.warn('failed to save configuration in %s', CACHE_FILE) + console.warn(error.message) + } + ) + + const plugin = createPlugin({}) + await plugin.configure(config) + + await plugin._authenticate({ + username: await input('Username', { + validate: input => !!input.length + }), + password: await password('Password') + }, ::console.log) +})