diff --git a/docs/source/NEWS.rst b/docs/source/NEWS.rst index 3ee28aa4..b5e93548 100644 --- a/docs/source/NEWS.rst +++ b/docs/source/NEWS.rst @@ -101,6 +101,15 @@ Fixes ``LDAPClientTestDriver`` in ``ldaptor.testutil`` grew a matching ``notifyOnDisconnect`` so tests can exercise this path (#231). +Docs +~~~~ + +- Rewrote the ``ldaptor_with_upn_bind`` example: the custom + ``handle_LDAPBindRequest`` now delegates to the stock + ``LDAPServer.handle_LDAPBindRequest`` after resolving a UPN-style + BIND DN, instead of copy-pasting the base-class logic. Docstrings + explain the UPN shape and the fall-through cases (#161). + 21.2.0 (2021-02-28) ------------------- diff --git a/docs/source/examples/ldaptor_with_upn_bind.py b/docs/source/examples/ldaptor_with_upn_bind.py index dd859675..93bd4de9 100644 --- a/docs/source/examples/ldaptor_with_upn_bind.py +++ b/docs/source/examples/ldaptor_with_upn_bind.py @@ -1,102 +1,79 @@ """ An ldaptor LDAP server which can authenticate based on UPN, as AD does. -The LDAP entry needs to have the `userPrincipalName` attribute set. - -dn: uid=bob,ou=people,dc=example,dc=org -objectclass: top -objectclass: person -objectClass: inetOrgPerson -uid: bob -cn: bobby -gn: Bob -sn: Roberts -mail: bob@example.org -homeDirectory: e:\\Users\\bob -userPassword: pass -userPrincipalName: bob@ad.example.org +The LDAP entry needs to have the ``userPrincipalName`` attribute set: + + dn: uid=bob,ou=people,dc=example,dc=org + objectclass: top + objectclass: person + objectClass: inetOrgPerson + uid: bob + cn: bobby + gn: Bob + sn: Roberts + mail: bob@example.org + homeDirectory: e:\\Users\\bob + userPassword: pass + userPrincipalName: bob@ad.example.org + +A UPN bind arrives as ``User.Name@ad.example.tld`` in the BIND DN slot +rather than a normal distinguished name. This server intercepts the BIND +request, looks up the entry whose ``userPrincipalName`` matches, and +rewrites the request's DN to that entry's real DN before delegating to +the stock :class:`LDAPServer` bind handler. Non-UPN BIND requests are +forwarded unchanged. """ from ldaptor import interfaces from ldaptor.protocols import pureldap -from ldaptor.protocols.ldap import distinguishedname, ldaperrors from twisted.internet import defer from ldaptor.protocols.ldap.ldapserver import LDAPServer class LDAPServerWithUPNBind(LDAPServer): """ - An LDAP server which support BIND using UPN similar to AD. + An LDAP server which supports BIND using a UPN (User Principal Name), + similar to Active Directory. """ _loginAttribute = b"userPrincipalName" - def handle_LDAPBindRequest(self, request, controls, reply): - if request.version != 3: - raise ldaperrors.LDAPProtocolError( - "Version %u not supported" % request.version - ) - - self.checkControls(controls) - - if request.dn == b"": - # anonymous bind - self.boundUser = None - return pureldap.LDAPBindResponse(resultCode=0) + @defer.inlineCallbacks + def handle_LDAPBindRequest(self, request, *args, **kwargs): + resolved = yield self._resolveUPNBindDN(request) + result = yield super().handle_LDAPBindRequest(resolved, *args, **kwargs) + return result + + @defer.inlineCallbacks + def _resolveUPNBindDN(self, request): + """ + If ``request`` looks like a UPN bind, resolve it to a real BIND DN. + + A UPN takes the form ``User.Name@ad.example.tld``: it contains an + ``@`` but no ``,``, so it is not a valid distinguished name. When + the shape matches, search the directory for the entry whose + ``userPrincipalName`` matches and return a rewritten + :class:`LDAPBindRequest` targeting that entry's DN. Otherwise + return ``request`` unchanged so the caller can fall through to the + normal DN-based bind path. + """ + if b"@" not in request.dn or b"," in request.dn: + # Not a UPN request; leave the DN alone. + return request root = interfaces.IConnectedLDAPEntry(self.factory) - - def _gotUPNResult(results): - if len(results) != 1: - # Not exactly one result, so this might not be an UNP. - return distinguishedname.DistinguishedName(request.dn) - - # A single result, so the UPN might exist. - return results[0].dn - - if b"@" in request.dn and b"," not in request.dn: - # This might be an UPN request. - filterText = b"(" + self._loginAttribute + b"=" + request.dn + b")" - d = root.search(filterText=filterText) - d.addCallback(_gotUPNResult) - else: - d = defer.succeed(distinguishedname.DistinguishedName(request.dn)) - - # Once the BIND DN is known, search for the LDAP entry. - d.addCallback(lambda dn: root.lookup(dn)) - - def _noEntry(fail): - """ - Called when the requested BIND DN was not found. - """ - fail.trap(ldaperrors.LDAPNoSuchObject) - return None - - d.addErrback(_noEntry) - - def _gotEntry(entry, auth): - """ - Called when the requested BIND DN was found. - """ - if entry is None: - raise ldaperrors.LDAPInvalidCredentials() - - d = entry.bind(auth) - - def _cb(entry): - """ - Called when BIND operation was successful. - """ - self.boundUser = entry - msg = pureldap.LDAPBindResponse( - resultCode=ldaperrors.Success.resultCode, - matchedDN=entry.dn.getText(), - ) - return msg - - d.addCallback(_cb) - return d - - d.addCallback(_gotEntry, request.auth) - - return d + filter_text = b"(" + self._loginAttribute + b"=" + request.dn + b")" + results = yield root.search(filterText=filter_text) + + if len(results) != 1: + # No unambiguous UPN match; fall through to the requested BIND + # DN and let the stock handler reject it as usual. + return request + + return pureldap.LDAPBindRequest( + version=request.version, + dn=results[0].dn.getText(), + auth=request.auth, + tag=request.tag, + sasl=request.sasl, + )