Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/source/NEWS.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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)
-------------------
Expand Down
143 changes: 60 additions & 83 deletions docs/source/examples/ldaptor_with_upn_bind.py
Original file line number Diff line number Diff line change
@@ -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,
)
Loading