@cepharum/ldap-bridge
Advanced tools
Comparing version 0.1.0 to 0.1.1
@@ -45,2 +45,10 @@ /** | ||
/** | ||
* Detects if provided domain name is covered by current backend. | ||
* | ||
* @param {string} domain domain name to test | ||
* @returns {boolean} true if backend is covering provided domain name | ||
*/ | ||
coversDomain( domain ) { return false; } // eslint-disable-line no-unused-vars | ||
/** | ||
* Detects if named user exists or not. | ||
@@ -62,4 +70,14 @@ * | ||
authenticate( name, token ) { return Promise.reject( new Error( "no backend" ) ); } // eslint-disable-line no-unused-vars | ||
/** | ||
* Qualifies attributes of entry to be returned to a searching LDAP client. | ||
* | ||
* @param {object} user information on user described by returned entry | ||
* @param {object} boundUser information on currently bound LDAP user | ||
* @param {object} attributes LDAP attributes to be qualified | ||
* @returns {object} qualified LDAP attributes | ||
*/ | ||
qualifyAttributes( user, boundUser, attributes ) { return attributes; } | ||
} | ||
exports.Backend = Backend; |
@@ -75,2 +75,7 @@ /** | ||
/** @inheritDoc */ | ||
coversDomain( domain ) { | ||
return domain === this.domain; | ||
} | ||
/** @inheritDoc */ | ||
exists( name ) { // eslint-disable-line no-unused-vars | ||
@@ -77,0 +82,0 @@ return Promise.resolve( true ); |
{ | ||
"name": "@cepharum/ldap-bridge", | ||
"version": "0.1.0", | ||
"version": "0.1.1", | ||
"description": "remotely backed LDAP authentication", | ||
@@ -5,0 +5,0 @@ "main": "server/index.js", |
@@ -68,1 +68,75 @@ # ldap-bridge | ||
In a production setup the service requires LDAP-side and backends to communicate over encrypted connections, only. You need to set **NODE_ENV** environment variable to `development` to work with non-encrypted LDAP server locally. | ||
## Attribute Qualification | ||
When delivering LDAP entries as results of a search query those results may be qualified depending on a found user's mail address. It supports the following mail address formats resulting in additional LDAP attribute values for **givenName**, **sn** (for surname) and **objectclass** returned: | ||
``` | ||
givenName.surname@domain.tld | ||
givenName.surname_objectClass@domain.tld | ||
givenName.anotherGivenName.surname@domain.tld | ||
givenName.anotherGivenName.surname_objectClass@domain.tld | ||
``` | ||
Either given name and the surname are converted to have leading capitals. Dashes are supported in those parts of address as well. Multiple given names separated by period in mail address are provided space-separated in resulting **givenName** attribute. | ||
For example, the mail address | ||
``` | ||
ann-mary.jane.miller-smith_admin@foo.com | ||
``` | ||
results in LDAP entry | ||
``` | ||
dn: uid=ann-mary.jane.miller-smith_admin,dc=foo,dc=com | ||
objectclass: top | ||
objectclass: user | ||
objectclass: admin | ||
uid: ann-mary.jane.miller-smith_admin | ||
givenName: Ann-Mary Jane | ||
sn: Miller-Smith | ||
``` | ||
## Fuzzy Queries | ||
### Sub-Addressing in LDAP Searches | ||
When searching for a user by mail address its sub-addressing part is ignored. So, searching for | ||
``` | ||
john.doe+office@foo.com | ||
``` | ||
results in | ||
``` | ||
dn: uid=john.doe,dc=foo,dc=com | ||
objectclass: top | ||
objectclass: user | ||
uid: john.doe | ||
givenName: John | ||
sn: Doe | ||
``` | ||
The **+office** sub-address is omitted. | ||
### Additional RDNs in Bind DN | ||
Any additional RDN is ignored on binding as a given user. The following bind DNs result in identical authentication requests against some matching backend: | ||
``` | ||
uid=john.doe,dc=foo,dc=com | ||
uid=john.doe,ou=people,dc=foo,dc=com | ||
uid=john.doe,ou=staff,ou=people,dc=foo,dc=com | ||
``` | ||
In either case the assumed user is described as: | ||
```json | ||
{ | ||
"mailbox": "john.doe", | ||
"domain": "foo.com", | ||
"address": "john.doe@foo.com" | ||
} | ||
``` |
@@ -176,2 +176,77 @@ /** | ||
/** | ||
* Extracts user information from provided DN. | ||
* | ||
* @param {DN|string} dn DN of user to process | ||
* @returns {{mailbox: *, address: string, domain: string, attribute: string}} extracted user information | ||
* @throws TypeError on invalid DN | ||
*/ | ||
static dnToUsername( dn ) { | ||
const _dn = typeof dn === "string" ? LDAP.parseDN( dn ) : dn; | ||
_dn.setFormat( { skipSpace: true } ); | ||
const first = _dn.rdns[0]; | ||
const segments = []; | ||
for ( let i = 1, num = _dn.rdns.length; i < num; i++ ) { | ||
const rdn = _dn.rdns[i]; | ||
if ( rdn.attrs.dc ) { | ||
segments.push( rdn.attrs.dc.value ); | ||
} else if ( segments.length > 0 ) { | ||
if ( i < num - 1 ) { | ||
throw new TypeError( `invalid DN ${_dn} for extracting username` ); | ||
} | ||
} | ||
} | ||
if ( first && segments.length > 1 ) { | ||
const attribute = Object.keys( first.attrs )[0]; | ||
if ( attribute && DefaultUsernameAttributes.indexOf( attribute.trim().toLowerCase() ) > -1 ) { | ||
const mailbox = first.attrs[attribute].value; | ||
const domain = segments.join( "." ); | ||
return { | ||
attribute, | ||
mailbox: mailbox, | ||
domain: domain, | ||
address: `${mailbox}@${domain}`, | ||
}; | ||
} | ||
} | ||
throw new TypeError( `invalid DN ${_dn} for extracting username` ); | ||
} | ||
/** | ||
* Qualifies attributes of entry to be returned to a searching LDAP client. | ||
* | ||
* @param {object} user information on user described by returned entry | ||
* @param {object} boundUser information on currently bound LDAP user | ||
* @param {object} attributes LDAP attributes to be qualified | ||
* @returns {object} qualified LDAP attributes | ||
*/ | ||
static qualifyAttributes( user, boundUser, attributes ) { | ||
const match = /^([^@_+]+)\.([^@_+.]+)(?:[_@]|$)/.exec( user.mailbox.trim().toLowerCase() ); | ||
if ( match ) { | ||
const converter = ( _, lead, letter ) => lead + letter.toUpperCase(); | ||
const overlay = { | ||
givenName: match[1].trim().replace( /(^|-|\.)([a-z])/g, converter ).replace( /\./g, " " ), | ||
sn: match[2].trim().replace( /(^|-)([a-z])/g, converter ), | ||
}; | ||
const flag = user.mailbox.replace( /^.+_/, "" ).trim(); | ||
if ( flag ) { | ||
overlay.objectclass = ( attributes.objectclass || [] ).concat( flag ); | ||
} | ||
return Object.assign( {}, attributes, overlay ); | ||
} | ||
return attributes; | ||
} | ||
/** | ||
* Registers handlers with server instance. | ||
@@ -189,47 +264,17 @@ * | ||
parsedBackendDN.setFormat( { skipSpace: true } ); | ||
server.search( backendDN, ( req, res, next ) => { | ||
debug( `searching for ${req.filter.toString()} in scope of ${backendDN}` ); | ||
let usernames; | ||
try { | ||
usernames = this.constructor.extractUsernames( req.filter ); | ||
} catch ( cause ) { | ||
const msg = `processing failed: ${cause.message}`; | ||
error( msg ); | ||
next( new LDAP.OperationsError( msg ) ); | ||
return; | ||
} | ||
debug( `filter is testing for ${usernames.length} username(s)` ); | ||
if ( usernames.length === 1 ) { | ||
const { attribute, mailbox, domain: mailboxDomain } = usernames[0]; | ||
if ( mailboxDomain === backendDomain ) { | ||
const dn = `${attribute}=${mailbox},${backendDN}`; | ||
debug( `searching for ${req.filter.toString()} in scope of ${backendDN} yielded match ${dn}` ); | ||
res.send( { | ||
dn, | ||
attributes: { | ||
objectclass: [ "top", "user" ], | ||
[attribute]: mailbox, | ||
}, | ||
} ); | ||
} | ||
} | ||
res.end(); | ||
search( req, res, next, backend, `scope of ${backendDN}`, this.constructor ); | ||
} ); | ||
server.bind( backendDN, ( req, res, next ) => { | ||
debug( `binding ${req.authentication} as ${req.name} in scope of ${backendDN}` ); | ||
req.name.setFormat( { skipSpace: true } ); | ||
debug( `${req.logId}: binding ${req.authentication} as ${req.name} in scope of ${backendDN}` ); | ||
if ( req.authentication !== "simple" ) { | ||
const msg = "must be simple bind"; | ||
error( msg ); | ||
error( `${req.logId}: ${msg}` ); | ||
next( new LDAP.AuthMethodNotSupportedError( msg ) ); | ||
@@ -239,5 +284,4 @@ return undefined; | ||
const parsed = /^\s*([^\s=,]+)=([^\s,]+),\s*(.+)\s*$/.exec( req.name ); | ||
if ( !parsed ) { | ||
error( `rejecting invalid DN ${req.name}` ); | ||
if ( !req.dn.childOf( parsedBackendDN ) ) { | ||
error( `${req.logId}: rejecting foreign bind DN ${req.name} in scope of ${backendDN}` ); | ||
next( new LDAP.InvalidDnSyntaxError( "DN must address child node of domain" ) ); | ||
@@ -247,4 +291,8 @@ return undefined; | ||
if ( DefaultUsernameAttributes.indexOf( parsed[1] ) < 0 ) { | ||
error( `rejecting invalid attribute ${parsed[1]} on binding in scope of ${backendDN}` ); | ||
const rdn = req.dn.rdns[0]; | ||
const attribute = Object.keys( rdn.attrs )[0]; | ||
const value = rdn.attrs[attribute].value; | ||
if ( DefaultUsernameAttributes.indexOf( attribute.toLowerCase() ) < 0 ) { | ||
error( `${req.logId}: rejecting invalid attribute ${attribute} on binding in scope of ${backendDN}` ); | ||
next( new LDAP.InvalidDnSyntaxError( "invalid DN attribute" ) ); | ||
@@ -254,10 +302,4 @@ return undefined; | ||
if ( !LDAP.parseDN( parsed[3] ).equals( parsedBackendDN ) ) { | ||
error( `rejecting foreign DN ${req.name} in scope of ${backendDN}` ); | ||
next( new LDAP.InvalidDnSyntaxError( "DN must address child node of domain" ) ); | ||
return undefined; | ||
} | ||
const address = `${value}@${backendDomain}`; | ||
const address = `${parsed[2]}@${backendDomain}`; | ||
return backend.exists( address ) | ||
@@ -267,6 +309,9 @@ .then( exists => { | ||
return backend.authenticate( address, req.credentials ) | ||
.then( () => res.end() ); | ||
.then( () => { | ||
res.end(); | ||
next(); | ||
} ); | ||
} | ||
error( `can't bind as unknown user ${address}` ); | ||
error( `${req.logId}: can't bind as unknown user ${address}` ); | ||
next( new LDAP.NoSuchObjectError( "user not found" ) ); | ||
@@ -277,3 +322,3 @@ | ||
.catch( cause => { | ||
error( `invalid credentials rejected on binding as user ${address} in scope of ${backendDN}: ${cause.stack}` ); | ||
error( `${req.logId}: invalid credentials rejected on binding as user ${address} in scope of ${backendDN}: ${cause.stack}` ); | ||
next( cause ); | ||
@@ -285,12 +330,31 @@ } ); | ||
server.search( "cn=search", ( req, res, next ) => { | ||
debug( `searching for ${req.filter.toString()} in all available backends` ); | ||
search( req, res, next, undefined, "all available backends", this.constructor ); | ||
} ); | ||
/** | ||
* Commonly handles LDAP search requests. | ||
* | ||
* @param {LDAP.SearchRequest} req LDAP request descriptor | ||
* @param {LDAP.SearchResponse} res LDAP response manager | ||
* @param {function(error: ?Error):void} next callback invoked when done or on failure | ||
* @param {?Backend} backend backend to use explicitly | ||
* @param {string} scope label on request's scope for use in log message | ||
* @param {class<LDAPServer>} utils context providing methods for translating data | ||
* @returns {void} | ||
*/ | ||
function search( req, res, next, backend, scope, utils ) { | ||
const boundAs = req.connection.ldap.bindDN; | ||
boundAs.setFormat( { skipSpace: true } ); | ||
debug( `${req.logId}: searching for ${req.filter.toString()} in ${scope} as ${boundAs}` ); | ||
let usernames; | ||
try { | ||
usernames = this.constructor.extractUsernames( req.filter ); | ||
usernames = utils.extractUsernames( req.filter ); | ||
} catch ( cause ) { | ||
const msg = `processing failed: ${cause.message}`; | ||
error( msg ); | ||
error( `${req.logId}: ${msg}` ); | ||
next( new LDAP.OperationsError( msg ) ); | ||
@@ -300,20 +364,30 @@ return; | ||
debug( `filter is testing for ${usernames.length} username(s)` ); | ||
debug( `${req.logId}: filter is testing for ${usernames.length} username(s)` ); | ||
const boundUser = boundAs.equals( "" ) || boundAs.equals( "cn=anonymous" ) ? undefined : utils.dnToUsername( boundAs ); | ||
if ( usernames.length < 1 && boundUser ) { | ||
usernames.push( boundUser ); | ||
} | ||
if ( usernames.length === 1 ) { | ||
const { attribute, mailbox, domain: mailboxDomain } = usernames[0]; | ||
const backend = backends.selectByName( mailboxDomain ); | ||
const user = usernames[0]; | ||
const { attribute, mailbox, domain: mailboxDomain } = user; | ||
if ( backend ) { | ||
const dn = `${attribute}=${mailbox},${this.constructor.domainToDN( backend.domain )}`; | ||
if ( !boundUser || ( boundUser.mailbox === mailbox && boundUser.domain === mailboxDomain ) ) { | ||
const _backend = backend || backends.selectByName( mailboxDomain ); | ||
debug( `searching for ${req.filter.toString()} yielded match ${dn}` ); | ||
if ( _backend && _backend.coversDomain( mailboxDomain ) ) { | ||
const dn = `${attribute}=${mailbox},${utils.domainToDN( _backend.domain )}`; | ||
res.send( { | ||
dn, | ||
attributes: { | ||
objectclass: [ "top", "user" ], | ||
[attribute]: mailbox, | ||
}, | ||
} ); | ||
debug( `${req.logId}: searching for ${req.filter.toString()} yielded match ${dn}` ); | ||
res.send( { | ||
dn, | ||
attributes: _backend.qualifyAttributes( user, boundUser, utils.qualifyAttributes( user, boundUser, { | ||
objectclass: [ "top", "user" ], | ||
[attribute]: mailbox, | ||
} ) ), | ||
} ); | ||
} | ||
} | ||
@@ -323,3 +397,4 @@ } | ||
res.end(); | ||
} ); | ||
next(); | ||
} | ||
} | ||
@@ -326,0 +401,0 @@ } |
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
License Policy Violation
LicenseThis package is not allowed per your license policy. Review the package's license to ensure compliance.
Found 1 instance in 1 package
35638
780
142