Add basic LDAP bind functionality

This commit is contained in:
garrettmills 2020-04-17 19:25:33 -05:00
parent 226b90b7bf
commit 68cc90899c
No known key found for this signature in database
GPG Key ID: 6ACD58D6ADACFC6E
18 changed files with 387 additions and 90 deletions

View File

@ -33,7 +33,9 @@ const FlitterUnits = {
*/ */
'Upload' : require('flitter-upload/UploadUnit'), 'Upload' : require('flitter-upload/UploadUnit'),
'LDAPServer' : require('./app/unit/LDAPServerUnit'), 'LDAPServer' : require('./app/unit/LDAPServerUnit'),
'LDAPRegistry' : require('./app/unit/LDAPRegistry'), 'LDAPMiddleware': require('./app/unit/LDAPMiddlewareUnit'),
'LDAPController': require('./app/unit/LDAPControllerUnit'),
'LDAPRoutingUnit': require('./app/unit/LDAPRoutingUnit'),
/* /*
* The Core Flitter Units * The Core Flitter Units

View File

@ -0,0 +1,59 @@
const LDAPController = require('./LDAPController')
const LDAP = require('ldapjs')
class UsersController extends LDAPController {
static get services() {
return [...super.services, 'output', 'ldap_server', 'models']
}
async search_people(req, res, next) {
global.ireq = req
}
async bind(req, res, next) {
const auth_dn = this.ldap_server.auth_dn()
// Make sure the DN is valid
if ( !req.dn.childOf(auth_dn) ) {
return next(new LDAP.InvalidCredentialsError())
}
// Get the user
const user = await this.get_user_from_dn(req.dn)
if ( !user ) {
return next(new LDAP.InvalidCredentialsError())
}
// Make sure the password matches the user record
if ( !await user.check_password(req.credentials) ) {
return next(new LDAP.InvalidCredentialsError())
}
// Make sure the user has permission to bind
if ( !user.can('ldap:bind') ) {
return next(new LDAP.InsufficientAccessRightsError())
}
this.output.success(`Successfully bound user ${user.uid} as DN: ${req.dn.format({skipSpace: true})}.`)
return res.end()
}
get_uid_from_dn(dn) {
const uid_field = this.ldap_server.config.schema.auth.user_id
try {
if ( typeof dn === 'string' ) dn = LDAP.parseDN(dn)
return dn.rdns[0].attrs[uid_field].value
} catch (e) {}
}
async get_user_from_dn(dn) {
const uid = this.get_uid_from_dn(dn)
if ( uid ) {
const User = this.models.get('auth:User')
return User.findOne({uid})
}
}
}
module.exports = exports = UsersController

View File

@ -0,0 +1,33 @@
const LDAPMiddleware = require('./LDAPMiddleware')
const LDAP = require('ldapjs')
class BindUserMiddleware extends LDAPMiddleware {
static get services() {
return [...super.services, 'canon', 'output', 'ldap_server']
}
async test(req, res, next) {
const bind_dn = req.connection.ldap.bindDN
if ( bind_dn.equals(this.ldap_server.anonymous()) ) {
this.output.warn(`Blocked anonymous LDAP request on user-protected route.`)
return next(new LDAP.InsufficientAccessRightsError())
}
const user = this.user_controller().get_uid_from_dn(bind_dn)
if ( !user || !user.can('ldap:bind') ) {
return next(new LDAP.InvalidCredentialsError())
}
req.user = user
req.bindDN = bind_dn
return next()
}
user_controller() {
return this.canon.get('ldap_controller::Users')
}
}
module.exports = exports = BindUserMiddleware

View File

@ -0,0 +1,15 @@
const LDAPMiddleware = require('./LDAPMiddleware')
class LDAPLoggerMiddleware extends LDAPMiddleware {
static get services() {
return [...super.services, 'app', 'output']
}
async test(req, res, next) {
let bind_dn = req.connection.ldap.bindDN
this.output.info(`${req.json.protocolOp} - as ${bind_dn ? bind_dn.format({skipSpace: true}) : 'N/A'} - target ${req.dn.format({skipSpace: true})}`)
return next()
}
}
module.exports = exports = LDAPLoggerMiddleware

View File

@ -1,27 +0,0 @@
const example_routes = {
prefix: 'dc=base',
middleware: [
],
search: {
},
bind: {
},
add: {
},
del: {
},
}
module.exports = exports = example_routes

View File

@ -0,0 +1,30 @@
const users_routes = {
prefix: false, // false | string
middleware: [
'Logger'
],
search: {
'ou=people': [
'ldap_middleware::BindUser',
'ldap_controller::Users.search_people',
],
},
bind: {
'ou=people': ['ldap_controller::Users.bind'],
},
add: {
},
del: {
},
}
module.exports = exports = users_routes

View File

@ -6,13 +6,23 @@ const AuthUser = require('flitter-auth/model/User')
* properties here as you need. * properties here as you need.
*/ */
class User extends AuthUser { class User extends AuthUser {
static get services() {
return [...super.services, 'auth']
}
static get schema() { static get schema() {
return {...super.schema, ...{ return {...super.schema, ...{
// other schema fields here // other schema fields here
}} }}
} }
// Other members and methods here async check_password(password) {
return this.get_provider().check_user_auth(this, password)
}
get_provider() {
return this.auth.get_provider(this.provider)
}
} }
module.exports = exports = User module.exports = exports = User

View File

@ -1,31 +0,0 @@
/*
* HomeLogger Middleware
* -------------------------------------------------------------
* This is a sample middleware. It simply prints a console message when
* the route that it is tied to is accessed. By default, it is called if
* the '/' route is accessed. It can be injected in routes globally using
* the global mw() function.
*/
const Middleware = require('libflitter/middleware/Middleware')
class HomeLogger extends Middleware {
static get services() {
return [...super.services, 'output']
}
/*
* Run the middleware test.
* This method is required by all Flitter middleware.
* It should either call the next function in the stack,
* or it should handle the response accordingly.
*/
test(req, res, next, args) {
this.output.debug('Home was accessed!')
/*
* Call the next function in the stack.
*/
next()
}
}
module.exports = HomeLogger

View File

@ -26,7 +26,6 @@ const index = {
* handler's exec() method. * handler's exec() method.
*/ */
middleware: [ middleware: [
['HomeLogger', {note: 'arguments can be specified as the second element in this array'}],
// 'MiddlewareName', // Or without arguments // 'MiddlewareName', // Or without arguments
], ],

View File

@ -0,0 +1,60 @@
const CanonicalUnit = require('libflitter/canon/CanonicalUnit')
const LDAPController = require('../ldap/controllers/LDAPController')
const StopError = require('libflitter/errors/StopError')
class LDAPControllerUnit extends CanonicalUnit {
static get name() {
return 'ldap_controllers'
}
static get services() {
return [...super.services, 'output']
}
constructor(base_directory = './app/ldap/controllers') {
super(base_directory)
this.canonical_item = 'ldap_controller'
this.suffix = '.controller.js'
}
async init_canonical_file({app, name, instance}) {
if ( instance.prototype instanceof LDAPController ) {
this.output.debug(`Registering LDAP controller: ${name}`)
return new instance()
} else {
this.output.error(`LDAP Controller ${name} must extend base class LDAPController.`)
throw new StopError(`LDAP Controller ${name} must extend base class LDAPController.`)
}
}
/**
* Resolve an unqualified canonical name to a registered canonical controller or method.
* @param {string} name
// * @returns {module:libflitter/controller/Controller~Controller|function}
*/
get(name) {
const name_parts = name.split('.')
const controller_instance = this.canonical_items[name_parts[0]]
if ( name_parts.length > 1 ) {
const method_instance = controller_instance[name_parts[1]].bind(controller_instance)
if ( name_parts > 2 ) {
let descending_value = method_instance
name_parts.slice(2).forEach(part => {
descending_value = descending_value[part]
})
return descending_value
} else {
return method_instance
}
}
// TODO this is a bug in libflitter too!
return controller_instance
}
}
module.exports = exports = LDAPControllerUnit

View File

@ -0,0 +1,41 @@
const CanonicalUnit = require('libflitter/canon/CanonicalUnit')
const LDAPMiddleware = require('../ldap/middleware/LDAPMiddleware')
const StopError = require('libflitter/errors/StopError')
class LDAPMiddlewareUnit extends CanonicalUnit {
static get services() {
return [...super.services, 'output']
}
static get name() {
return 'ldap_middleware'
}
constructor(base_directory = './app/ldap/middleware') {
super(base_directory)
this.canonical_item = 'ldap_middleware'
this.suffix = '.middleware.js'
}
async init_canonical_file({app, name, instance}) {
if ( instance.prototype instanceof LDAPMiddleware ) {
this.output.debug(`Registering LDAP middleware: ${name}`)
return new instance()
} else {
this.output.error(`LDAP middleware class ${name} must be an instance of LDAPMiddleware.`)
throw new StopError(`LDAP middleware class ${name} must be an instance of LDAPMiddleware.`)
}
}
get(name) {
const item = super.get(name)
if ( item instanceof LDAPMiddleware ) {
return item.test.bind(item)
}
return item
}
}
module.exports = exports = LDAPMiddlewareUnit

View File

@ -1,21 +0,0 @@
const Unit = require('libflitter/Unit')
class LDAPRegistry extends Unit {
static get name() {
return 'ldap_registry'
}
static get services() {
return [...super.services, 'output']
}
async go(app) {
}
async cleanup(app) {
}
}
module.exports = exports = LDAPRegistry

101
app/unit/LDAPRoutingUnit.js Normal file
View File

@ -0,0 +1,101 @@
const CanonicalUnit = require('libflitter/canon/CanonicalUnit')
class LDAPRoutingUnit extends CanonicalUnit {
static get name() {
return 'ldap_routers'
}
static get services() {
return [...super.services, 'output', 'canon', 'ldap_server']
}
constructor(base_directory = './app/ldap/routes') {
super(base_directory)
this.canonical_item = 'ldap_router'
this.suffix = '.routes.js'
}
async init_canonical_file({app, name, instance}) {
const router_middleware = []
if ( !instance ) {
this.output.warn(`Skipping LDAP routing file ${name}. No members were exported.`)
}
this.output.info(`Building LDAP routes for router ${name}.`)
// Load the router-level middleware functions
if ( Array.isArray(instance.middleware) ) {
for ( const mw of instance.middleware ) {
const mw_instance = this.canon.get(`ldap_middleware::${mw}`)
if ( !mw_instance ) {
const msg = `Unable to create LDAP routes. Invalid or unknown LDAP middleware: ldap_middleware::${mw} in router ${name}.`
this.output.error(msg)
throw new Error(msg)
}
router_middleware.push(mw_instance)
}
}
this.output.debug(`Found ${router_middleware.length} router-level middlewares.`)
// Determine the prefix (suffix) and total suffix from config
let suffix = []
if ( instance.prefix && typeof instance.prefix === 'string' ) {
suffix.push(instance.prefix)
} else if ( instance.prefix !== false ) {
this.output.warn(`No prefix specified for LDAP routing file ${name}.`)
}
// If the server has a base DC=...,DC=... &c. suffix, include that
if ( this.ldap_server.config.schema.base_dc ) {
suffix.push(this.ldap_server.config.schema.base_dc)
}
suffix = suffix.join(',')
// Load the individual routes
const supported_ldap_types = [
'search', 'bind', 'add', 'del', 'modify', 'compare', 'modifyDN', 'exop', 'unbind',
]
// Iterate over the various query types that might be in the definition
for ( const type of supported_ldap_types ) {
if ( typeof instance[type] === 'object' ) {
// Iterate over each of the route definitions in the type definition
for ( const route_prefix in instance[type] ) {
if ( !instance[type].hasOwnProperty(route_prefix) ) continue
let route_handlers = instance[type][route_prefix]
if ( typeof route_handlers === 'string' ) route_handlers = [route_handlers]
if ( !Array.isArray(route_handlers) ) {
const msg = `Invalid route handlers for route ${route_prefix} (${type}) in router ${name}.`
this.output.error(msg)
throw new Error(msg)
}
let route_functions = [...router_middleware]
// For each of the route handler definitions, resolve the canonical
for ( const route_handler_name of route_handlers ) {
const route_handler = this.canon.get(route_handler_name)
if ( !route_handler || typeof route_handler !== 'function' ) {
const msg = `Unable to resolve route handler for route ${route_prefix} (${type}) in router ${name}. Handler name: ${route_handler_name}`
this.output.error(msg)
throw new Error(msg)
}
route_functions.push(route_handler)
}
this.output.debug(`Registering route ${type} :: ${[route_prefix, suffix].join(',')} with ${route_functions.length} handlers.`)
this.ldap_server.server[type]([route_prefix, suffix].join(','), ...route_functions)
}
} else {
this.output.warn(`Missing or invalid LDAP protocol definition ${type} in router ${name}. The protocol will be skipped.`)
}
}
}
}
module.exports = exports = LDAPRoutingUnit

View File

@ -10,6 +10,20 @@ class LDAPServerUnit extends Unit {
return [...super.services, 'configs', 'express', 'output'] return [...super.services, 'configs', 'express', 'output']
} }
auth_dn() {
return this.build_dn(this.config.schema.authentication_base)
}
anonymous() {
return LDAP.parseDN('cn=anonymous')
}
build_dn(...parts) {
parts = parts.flat()
parts.push(this.config.schema.base_dc)
return LDAP.parseDN(parts.join(','))
}
async go(app) { async go(app) {
this.config = this.configs.get('ldap:server') this.config = this.configs.get('ldap:server')
const server_config = {} const server_config = {}
@ -17,7 +31,7 @@ class LDAPServerUnit extends Unit {
// If Flitter is configured to use an SSL certificate, // If Flitter is configured to use an SSL certificate,
// use it to enable LDAPS in the server. // use it to enable LDAPS in the server.
if ( this.express.use_ssl() ) { if ( this.express.use_ssl() ) {
this.output.info('[LDAP Server] Using configured SSL certificate to enable LDAPS.') this.output.info('Using configured SSL certificate. The LDAP server will require an ldaps:// connection.')
server_config.certificate = await this.express.ssl_certificate() server_config.certificate = await this.express.ssl_certificate()
server_config.key = await this.express.ssl_key() server_config.key = await this.express.ssl_key()
} }
@ -28,9 +42,10 @@ class LDAPServerUnit extends Unit {
this.server.maxConnections = this.config.max_connections this.server.maxConnections = this.config.max_connections
} }
this.output.info(`[LDAP Server] Will listen on ${this.config.interface}:${this.config.port}`) this.output.info(`Will listen on ${this.config.interface}:${this.config.port}`)
await new Promise((res, rej) => { await new Promise((res, rej) => {
this.server.listen(this.config.port, this.config.interface, () => { this.server.listen(this.config.port, this.config.interface, () => {
this.output.success(`LDAP server listening on port ${this.config.port}...`)
res() res()
}) })
}) })

View File

@ -1,8 +1,17 @@
// LDAP Server Configuration // LDAP Server Configuration
const ldap_server = { const ldap_server = {
port: env('LDAP_SERVER_PORT', 389), port: env('LDAP_SERVER_PORT', 389),
max_connections: env('LDAP_MAX_CONNECTIONS'), max_connections: env('LDAP_MAX_CONNECTIONS'),
interface: env('LDAP_LISTEN_INTERFACE', '0.0.0.0'), interface: env('LDAP_LISTEN_INTERFACE', '0.0.0.0'),
schema: {
base_dc: env('LDAP_BASE_DC', 'dc=example,dc=com'),
authentication_base: env('LDAP_AUTH_BASE', 'ou=people'),
auth: {
user_id: 'uid',
}
}
} }
module.exports = exports = ldap_server module.exports = exports = ldap_server

View File

@ -20,7 +20,9 @@ const server_config = {
* The logging level. Usually, 1-4. * The logging level. Usually, 1-4.
* The higher the level, the more information is logged. * The higher the level, the more information is logged.
*/ */
level: env("LOGGING_LEVEL", 1) level: env("LOGGING_LEVEL", 1),
include_timestamp: env("LOGGING_TIMESTAMP", false),
}, },
session: { session: {

View File

@ -24,6 +24,6 @@
"flitter-orm": "^0.2.4", "flitter-orm": "^0.2.4",
"flitter-upload": "^0.8.0", "flitter-upload": "^0.8.0",
"ldapjs": "^1.0.2", "ldapjs": "^1.0.2",
"libflitter": "^0.47.0" "libflitter": "^0.48.1"
} }
} }

View File

@ -1571,10 +1571,10 @@ leven@^1.0.2:
resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3" resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3"
integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM= integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM=
libflitter@^0.47.0: libflitter@^0.48.1:
version "0.47.0" version "0.48.1"
resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.47.0.tgz#a28253458316f9ae4d8e10f7058af512b82c0e01" resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.48.1.tgz#a40110bfad63086e8d2321e288df58ac18dece77"
integrity sha512-M01HtrkD1bFwrslYzA/5V3Ozv67iwJAoFBuh3c5BmADCchur6x84w3jPnl8tr7tdDjHr7HtH8ahOgFhI1QZGAw== integrity sha512-C5GvIvl/vpWd9j+9HieI7qQa3edN6RaodyV5w6lLcJmuOsIMqd47sALbz8og+jPjE5rQuIo5l/zE5OrgxrPI1w==
dependencies: dependencies:
colors "^1.3.3" colors "^1.3.3"
connect-mongodb-session "^2.2.0" connect-mongodb-session "^2.2.0"