diff --git a/lib/cli.js b/lib/cli.js index a21f6bff..f464997e 100644 --- a/lib/cli.js +++ b/lib/cli.js @@ -107,6 +107,67 @@ program }) }) +program + .command('auth') + .description('start auth server') + .option('-p, --port ' + , 'port (or $PORT)' + , Number + , 7100) + .option('-s, --secret ' + , 'secret (or $SECRET)' + , String) + .option('-i, --ssid ' + , 'session SSID (or $SSID)' + , String + , 'ssid') + .option('-u, --ldap-url ' + , 'LDAP server URL (or $LDAP_URL)' + , String) + .option('-t, --ldap-timeout ' + , 'LDAP timeout (or $LDAP_TIMEOUT)' + , Number + , 1000) + .option('--ldap-bind-dn ' + , 'LDAP bind DN (or $LDAP_BIND_DN)' + , String) + .option('--ldap-bind-credentials ' + , 'LDAP bind credentials (or $LDAP_BIND_CREDENTIALS)' + , String) + .option('--ldap-search-dn ' + , 'LDAP search DN (or $LDAP_SEARCH_DN)' + , String) + .option('--ldap-search-scope ' + , 'LDAP search scope (or $LDAP_SEARCH_SCOPE)' + , String + , 'sub') + .option('--ldap-search-class ' + , 'LDAP search objectClass (or $LDAP_SEARCH_CLASS)' + , String + , 'user') + .action(function(options) { + var env = process.env + require('./roles/auth')({ + port: env.PORT || options.port + , secret: env.SECRET || options.secret + , ssid: env.SSID || options.ssid + , ldap: { + url: env.LDAP_URL || options.ldapUrl + , timeout: env.LDAP_TIMEOUT || options.ldapTimeout + , bind: { + dn: env.LDAP_BIND_DN || options.ldapBindDn + , credentials: env.LDAP_BIND_CREDENTIALS || options.ldapBindCredentials + } + , search: { + dn: env.LDAP_SEARCH_DN || options.ldapSearchDn + , scope: env.LDAP_SEARCH_SCOPE || options.ldapSearchScope + , objectClass: env.LDAP_SEARCH_CLASS || options.ldapSearchClass + , loginField: env.LDAP_SEARCH_LOGINFIELD || options.ldapSearchLoginField + } + } + }) + }) + program .command('console') .description('start console') diff --git a/lib/roles/auth.js b/lib/roles/auth.js new file mode 100644 index 00000000..cee5a2bc --- /dev/null +++ b/lib/roles/auth.js @@ -0,0 +1,102 @@ +var url = require('url') + +var express = require('express') +var validator = require('express-validator') + +var logger = require('../util/logger') +var requtil = require('../util/requtil') +var ldaputil = require('../util/ldaputil') +var jwtutil = require('../util/jwtutil') + +module.exports = function(options) { + var log = logger.createLogger('app') + , app = express() + + app.use(express.cookieParser()) + app.use(express.cookieSession({ + secret: options.secret + , key: options.ssid + })) + app.use(express.json()) + app.use(express.urlencoded()) + app.use(validator()) + + app.get('/auth', function(req, res) { + res.locals.csrf = req.csrfToken() + }) + + app.post('/auth', function(req, res) { + var log = logger.createLogger('auth') + log.setLocalIdentifier(req.ip) + switch (req.accepts(['json'])) { + case 'json': + requtil.validate(req, function() { + req.checkBody('username').notEmpty() + req.checkBody('password').notEmpty() + + // This is a security risk. Someone might forward the user + // to the login page with their own redirect set, and they'd + // then be able to steal the token. Some kind of a whitelist + // or a fixed redirect URL is needed. + req.checkBody('redirect').isUrl() + }) + .then(function() { + return ldaputil.login( + options.ldap + , req.body.username + , req.body.password + ) + }) + .then(function(user) { + log.info('Authenticated "%s"', user.userPrincipalName) + var token = jwtutil.encode({ + payload: { + email: user.userPrincipalName + , name: user.cn + } + , secret: options.secret + }) + var target = url.parse(req.body.redirect) + target.query = { + jwt: token + } + res.status(200) + .json({ + success: true + , redirect: url.format(target) + }) + }) + .catch(requtil.ValidationError, function(err) { + res.status(400) + .json({ + success: false + , error: 'ValidationError' + , validationErrors: err.errors + }) + }) + .catch(ldaputil.InvalidCredentialsError, function(err) { + log.warn('Authentication failure for "%s"', err.user) + res.status(400) + .json({ + success: false + , error: 'InvalidCredentialsError' + }) + }) + .catch(function(err) { + log.error('Unexpected error', err.stack) + res.status(500) + .json({ + success: false + , error: 'ServerError' + }) + }) + break + default: + res.send(406) + break + } + }) + + app.listen(options.port) + log.info('Listening on port %d', options.port) +} diff --git a/lib/util/jwtutil.js b/lib/util/jwtutil.js new file mode 100644 index 00000000..904b0407 --- /dev/null +++ b/lib/util/jwtutil.js @@ -0,0 +1,33 @@ +var assert = require('assert') +var jws = require('jws') + +module.exports.encode = function(options) { + assert.ok(options.payload, 'payload required') + assert.ok(options.secret, 'secret required') + + return jws.sign({ + header: { + alg: 'HS256' + , exp: Date.now() + 24 * 3600 + } + , payload: options.payload + , secret: options.secret + }) +} + +module.exports.decode = function(payload, secret) { + if (!jws.verify(payload, secret)) { + return null + } + + var decoded = jws.decode(payload, { + json: true + }) + , exp = decoded.header.exp + + if (exp && exp <= Date.now()) { + return null + } + + return decoded.payload +} diff --git a/lib/util/ldaputil.js b/lib/util/ldaputil.js new file mode 100644 index 00000000..fd8c6747 --- /dev/null +++ b/lib/util/ldaputil.js @@ -0,0 +1,111 @@ +var util = require('util') + +var ldap = require('ldapjs') +var Promise = require('bluebird') + +function InvalidCredentialsError(user) { + Error.call(this, util.format('Invalid credentials for user "%s"', user)) + this.name = 'InvalidCredentialsError' + this.user = user + Error.captureStackTrace(this, InvalidCredentialsError) +} + +util.inherits(InvalidCredentialsError, Error) + +// Export +module.exports.InvalidCredentialsError = InvalidCredentialsError + +// Export +module.exports.login = function(options, username, password) { + function tryConnect() { + var resolver = Promise.defer() + , client = ldap.createClient({ + url: options.url + , timeout: options.timeout + , maxConnections: 1 + }) + + client.bind(options.bind.dn, options.bind.credentials, function(err) { + if (err) { + resolver.reject(err) + } + else { + resolver.resolve(client) + } + }) + + return resolver.promise + } + + function tryFind(client) { + var resolver = Promise.defer() + , query = { + scope: options.search.scope + , filter: new ldap.AndFilter({ + filters: [ + new ldap.EqualityFilter({ + attribute: 'objectClass' + , value: options.search.objectClass + }) + , new ldap.EqualityFilter({ + attribute: options.search.loginField + , value: username + }) + ] + }) + } + + client.search(options.search.dn, query, function(err, search) { + if (err) { + return resolver.reject(err) + } + + function entryListener(entry) { + resolver.resolve(entry) + } + + function endListener() { + resolver.reject(new InvalidCredentialsError(username)) + } + + function errorListener(err) { + resolver.reject(err) + } + + search.on('searchEntry', entryListener) + search.on('end', endListener) + search.on('error', errorListener) + + resolver.promise.finally(function() { + search.removeListener('searchEntry', entryListener) + search.removeListener('end', endListener) + search.removeListener('error', errorListener) + }) + }) + + return resolver.promise + } + + function tryBind(client, entry) { + return new Promise(function(resolve, reject) { + client.bind(entry.object.dn, password, function(err) { + if (err) { + reject(new InvalidCredentialsError(username)) + } + else { + resolve(entry.object) + } + }) + }) + } + + return tryConnect().then(function(client) { + return tryFind(client) + .then(function(entry) { + return tryBind(client, entry) + }) + .finally(function() { + client.unbind() + }) + }) +} diff --git a/lib/util/logger.js b/lib/util/logger.js index 5270a02e..988d8165 100644 --- a/lib/util/logger.js +++ b/lib/util/logger.js @@ -11,6 +11,11 @@ function Log(tag, stream) { , ERROR: 'ERR'.red , FATAL: 'FTL'.red } + this.localIdentifier = null +} + +Log.prototype.setLocalIdentifier = function(identifier) { + this.localIdentifier = identifier } Log.prototype.debug = function() { @@ -39,7 +44,8 @@ Log.prototype.fatal = function() { Log.prototype._format = function(priority, args) { return util.format('%s/%s %d [%s] %s', - priority, this.tag, process.pid, Log.globalIdentifier, + priority, this.tag, process.pid, + this.localIdentifier || Log.globalIdentifier, util.format.apply(util, args)) } diff --git a/lib/util/requtil.js b/lib/util/requtil.js new file mode 100644 index 00000000..35390962 --- /dev/null +++ b/lib/util/requtil.js @@ -0,0 +1,28 @@ +var util = require('util') + +var Promise = require('bluebird') + +function ValidationError(message, errors) { + Error.call(this, message) + this.name = 'ValidationError' + this.errors = errors + Error.captureStackTrace(this, ValidationError) +} + +util.inherits(ValidationError, Error) + +module.exports.ValidationError = ValidationError + +module.exports.validate = function(req, rules) { + return new Promise(function(resolve, reject) { + rules() + + var errors = req.validationErrors() + if (!errors) { + resolve() + } + else { + reject(new ValidationError('validation error', errors)) + } + }) +}