Merge remote-tracking branch 'xo-server-auth-ldap/master'

This commit is contained in:
Julien Fontanet 2017-01-13 14:00:37 +01:00
commit e08689ff0e
9 changed files with 785 additions and 0 deletions

View File

@ -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

View File

@ -0,0 +1,8 @@
/dist/
/ldap.cache.conf
/node_modules/
npm-debug.log
npm-debug.log.*
pnpm-debug.log
pnpm-debug.log.*

View File

@ -0,0 +1,10 @@
/examples/
example.js
example.js.map
*.example.js
*.example.js.map
/test/
/tests/
*.spec.js
*.spec.js.map

View File

@ -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

View File

@ -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)

View File

@ -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"
}
}

View File

@ -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 \`<user>@<domain>\`.
`.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}}@<domain>)\`
- \`(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)

View File

@ -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)
}

View File

@ -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)
})