diff --git a/Units.flitter.js b/Units.flitter.js index 7252a21..7d654bc 100644 --- a/Units.flitter.js +++ b/Units.flitter.js @@ -33,7 +33,9 @@ const FlitterUnits = { */ 'Upload' : require('flitter-upload/UploadUnit'), '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 diff --git a/app/ldap/controllers/Users.controller.js b/app/ldap/controllers/Users.controller.js new file mode 100644 index 0000000..9d0c717 --- /dev/null +++ b/app/ldap/controllers/Users.controller.js @@ -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 diff --git a/app/ldap/middleware/BindUser.middleware.js b/app/ldap/middleware/BindUser.middleware.js new file mode 100644 index 0000000..9cd757b --- /dev/null +++ b/app/ldap/middleware/BindUser.middleware.js @@ -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 diff --git a/app/ldap/middleware/Logger.middleware.js b/app/ldap/middleware/Logger.middleware.js new file mode 100644 index 0000000..d27e46d --- /dev/null +++ b/app/ldap/middleware/Logger.middleware.js @@ -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 diff --git a/app/ldap/routes/example.routes.js b/app/ldap/routes/example.routes.js deleted file mode 100644 index 30a1aff..0000000 --- a/app/ldap/routes/example.routes.js +++ /dev/null @@ -1,27 +0,0 @@ -const example_routes = { - - prefix: 'dc=base', - - middleware: [ - - ], - - search: { - - }, - - bind: { - - }, - - add: { - - }, - - del: { - - }, - -} - -module.exports = exports = example_routes diff --git a/app/ldap/routes/users.routes.js b/app/ldap/routes/users.routes.js new file mode 100644 index 0000000..8103d2f --- /dev/null +++ b/app/ldap/routes/users.routes.js @@ -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 diff --git a/app/models/auth/User.model.js b/app/models/auth/User.model.js index 1c92291..1a8cd8c 100644 --- a/app/models/auth/User.model.js +++ b/app/models/auth/User.model.js @@ -6,13 +6,23 @@ const AuthUser = require('flitter-auth/model/User') * properties here as you need. */ class User extends AuthUser { + static get services() { + return [...super.services, 'auth'] + } + static get schema() { return {...super.schema, ...{ // 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 diff --git a/app/routing/middleware/HomeLogger.middleware.js b/app/routing/middleware/HomeLogger.middleware.js deleted file mode 100644 index 150d826..0000000 --- a/app/routing/middleware/HomeLogger.middleware.js +++ /dev/null @@ -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 diff --git a/app/routing/routers/index.routes.js b/app/routing/routers/index.routes.js index ce66ba8..b695506 100644 --- a/app/routing/routers/index.routes.js +++ b/app/routing/routers/index.routes.js @@ -26,7 +26,6 @@ const index = { * handler's exec() method. */ middleware: [ - ['HomeLogger', {note: 'arguments can be specified as the second element in this array'}], // 'MiddlewareName', // Or without arguments ], diff --git a/app/unit/LDAPControllerUnit.js b/app/unit/LDAPControllerUnit.js new file mode 100644 index 0000000..ffd8d61 --- /dev/null +++ b/app/unit/LDAPControllerUnit.js @@ -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 diff --git a/app/unit/LDAPMiddlewareUnit.js b/app/unit/LDAPMiddlewareUnit.js new file mode 100644 index 0000000..1f4b1f7 --- /dev/null +++ b/app/unit/LDAPMiddlewareUnit.js @@ -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 diff --git a/app/unit/LDAPRegistry.js b/app/unit/LDAPRegistry.js deleted file mode 100644 index 9ccb26f..0000000 --- a/app/unit/LDAPRegistry.js +++ /dev/null @@ -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 diff --git a/app/unit/LDAPRoutingUnit.js b/app/unit/LDAPRoutingUnit.js new file mode 100644 index 0000000..0c8d5e4 --- /dev/null +++ b/app/unit/LDAPRoutingUnit.js @@ -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 diff --git a/app/unit/LDAPServerUnit.js b/app/unit/LDAPServerUnit.js index dbd4750..4219d82 100644 --- a/app/unit/LDAPServerUnit.js +++ b/app/unit/LDAPServerUnit.js @@ -10,6 +10,20 @@ class LDAPServerUnit extends Unit { 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) { this.config = this.configs.get('ldap:server') const server_config = {} @@ -17,7 +31,7 @@ class LDAPServerUnit extends Unit { // If Flitter is configured to use an SSL certificate, // use it to enable LDAPS in the server. 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.key = await this.express.ssl_key() } @@ -28,9 +42,10 @@ class LDAPServerUnit extends Unit { 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) => { this.server.listen(this.config.port, this.config.interface, () => { + this.output.success(`LDAP server listening on port ${this.config.port}...`) res() }) }) diff --git a/config/ldap/server.config.js b/config/ldap/server.config.js index 9b16859..00444f4 100644 --- a/config/ldap/server.config.js +++ b/config/ldap/server.config.js @@ -1,8 +1,17 @@ // LDAP Server Configuration const ldap_server = { + port: env('LDAP_SERVER_PORT', 389), max_connections: env('LDAP_MAX_CONNECTIONS'), 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 diff --git a/config/server.config.js b/config/server.config.js index 663841c..261299a 100644 --- a/config/server.config.js +++ b/config/server.config.js @@ -20,7 +20,9 @@ const server_config = { * The logging level. Usually, 1-4. * 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: { diff --git a/package.json b/package.json index 8f9eb58..9dc0750 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,6 @@ "flitter-orm": "^0.2.4", "flitter-upload": "^0.8.0", "ldapjs": "^1.0.2", - "libflitter": "^0.47.0" + "libflitter": "^0.48.1" } } diff --git a/yarn.lock b/yarn.lock index b3fac22..8b358a5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1571,10 +1571,10 @@ leven@^1.0.2: resolved "https://registry.yarnpkg.com/leven/-/leven-1.0.2.tgz#9144b6eebca5f1d0680169f1a6770dcea60b75c3" integrity sha1-kUS27ryl8dBoAWnxpncNzqYLdcM= -libflitter@^0.47.0: - version "0.47.0" - resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.47.0.tgz#a28253458316f9ae4d8e10f7058af512b82c0e01" - integrity sha512-M01HtrkD1bFwrslYzA/5V3Ozv67iwJAoFBuh3c5BmADCchur6x84w3jPnl8tr7tdDjHr7HtH8ahOgFhI1QZGAw== +libflitter@^0.48.1: + version "0.48.1" + resolved "https://registry.yarnpkg.com/libflitter/-/libflitter-0.48.1.tgz#a40110bfad63086e8d2321e288df58ac18dece77" + integrity sha512-C5GvIvl/vpWd9j+9HieI7qQa3edN6RaodyV5w6lLcJmuOsIMqd47sALbz8og+jPjE5rQuIo5l/zE5OrgxrPI1w== dependencies: colors "^1.3.3" connect-mongodb-session "^2.2.0"