LDAP Clients

The following recipies demonstrate asynchronous LDAP clients.

A Minimal Client Using Endpoints

While Ldaptor exposes helper classes to connect clients to the DIT, it is possible to use the Twisted endpoints API to connect an Ldaptor client to a server.

Code

 1#! /usr/bin/env python
 2
 3import sys
 4
 5from ldaptor.protocols.ldap.ldapclient import LDAPClient
 6from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry
 7from twisted.internet.defer import inlineCallbacks
 8from twisted.internet.endpoints import clientFromString, connectProtocol
 9from twisted.internet.task import react
10from twisted.python import log
11
12
13@inlineCallbacks
14def onConnect(clientProtocol):
15    o = LDAPEntry(clientProtocol, "dc=fr")
16    results = yield o.search()
17    data = "".join([result.getLDIF() for result in results])
18    log.msg(f"LDIF formatted results:\n{data}")
19
20
21def onError(err, reactor):
22    if reactor.running:
23        log.err(err)
24        reactor.stop()
25
26
27def main(reactor):
28    log.startLogging(sys.stdout)
29    endpoint_str = "tcp:host=localhost:port=8080"
30    e = clientFromString(reactor, endpoint_str)
31    d = connectProtocol(e, LDAPClient())
32    d.addCallback(onConnect)
33    d.addErrback(onError, reactor)
34    return d
35
36
37react(main)

Discussion

The twisted.internet.task.react() function is perfect for running a one-shot main() function. When main() is called, we create a client endpoint from a string description and the reactor. twisted.internet.endpoints.connectProtocol() is used to make a one-time connection to an LDAP directory listening on the local host, port 8080. When the deferred returned from that function fires, the connection has been established and the client protocol instance is passed to the onConnect() callback.

This callback uses inline deferreds to make the syntax more compact. We create an ldaptor.protocols.ldap.ldapsyntax.LDAPEntry with a DN matching the root of the directory and call the asynchronous search() method. The result returned when the deferred fires is a list of LDAPEntry objects.

When cast as strings, these entries are formatted as LDIF.

Searching with the Paged Search Result Control

Some DITs place limits on the number of entries they are willing to return as the result of a LDAP SEARCH request. Microsoft’s Active Directory is one such service. In order to query and process large result sets, you can use the paged result control (OID 1.2.840.113556.1.4.319) if you DIT supports it.

The paged result control allows you to request a particular page size. The DIT will return a response control that has a magic cookie if the there are additional pages of results. You can use the cookie on a new request to process the results one page at a time.

Code

For ad.example.com domain, store the admin password in a file named pass_file and run the following example, where 10.20.1.2 is replaced with the IP of your AD server:

python docs/source/cookbook/client_paged_search_results.py \
    tcp:host=10.20.1.2:port=389 \
    'CN=Administrator,CN=Users,DC=ad,DC=example,DC=com' \
    pass_file \
    'CN=Users,DC=ad,DC=example,DC=com' \
    --page-size 5

The output should look like:

Page 1
CN=Users,DC=ad,DC=example,DC=com
CN=Administrator,CN=Users,DC=ad,DC=example,DC=com
CN=Guest,CN=Users,DC=ad,DC=example,DC=com
CN=SUPPORT_388945a0,CN=Users,DC=ad,DC=example,DC=com
CN=HelpServicesGroup,CN=Users,DC=ad,DC=example,DC=com
Page 2
CN=TelnetClients,CN=Users,DC=ad,DC=example,DC=com
CN=krbtgt,CN=Users,DC=ad,DC=example,DC=com
CN=Domain Computers,CN=Users,DC=ad,DC=example,DC=com
There were 8 results returned in total.
  1#! /usr/bin/env python
  2
  3import argparse
  4import sys
  5
  6from twisted.internet import defer
  7from twisted.internet.endpoints import clientFromString, connectProtocol
  8from twisted.internet.task import react
  9from ldaptor.protocols.ldap.ldapclient import LDAPClient
 10from ldaptor.protocols.ldap.ldapsyntax import LDAPEntry
 11from ldaptor.protocols import pureber
 12
 13
 14@defer.inlineCallbacks
 15def onConnect(client, args):
 16    binddn = args.bind_dn
 17    bindpw = args.passwd_file.read().strip()
 18    if args.start_tls:
 19        yield client.startTLS()
 20    try:
 21        yield client.bind(binddn, bindpw)
 22    except Exception as ex:
 23        print(ex)
 24        raise
 25    page_size = args.page_size
 26    cookie = ""
 27    page = 1
 28    count = 0
 29    while True:
 30        results, cookie = yield process_entry(
 31            client, args, args.filter, page_size=page_size, cookie=cookie
 32        )
 33        count += len(results)
 34        print(f"Page {page}")
 35        display_results(results)
 36        if len(cookie) == 0:
 37            break
 38        page += 1
 39    print(f"There were {count} results returned in total.")
 40
 41
 42@defer.inlineCallbacks
 43def process_entry(client, args, search_filter, page_size=100, cookie=""):
 44    basedn = args.base_dn
 45    control_value = pureber.BERSequence(
 46        [
 47            pureber.BERInteger(page_size),
 48            pureber.BEROctetString(cookie),
 49        ]
 50    )
 51    controls = [("1.2.840.113556.1.4.319", None, control_value)]
 52    o = LDAPEntry(client, basedn)
 53    results, resp_controls = yield o.search(
 54        filterText=search_filter,
 55        attributes=["dn"],
 56        controls=controls,
 57        return_controls=True,
 58    )
 59    cookie = get_paged_search_cookie(resp_controls)
 60    defer.returnValue((results, cookie))
 61
 62
 63def display_results(results):
 64    for entry in results:
 65        print(entry.dn.getText())
 66
 67
 68def get_paged_search_cookie(controls):
 69    """
 70    Input: semi-parsed controls list from LDAP response;
 71    list of tuples (controlType, criticality, controlValue).
 72    Parses the controlValue and returns the cookie as a byte string.
 73    """
 74    control_value = controls[0][2]
 75    ber_context = pureber.BERDecoderContext()
 76    ber_seq, bytes_used = pureber.berDecodeObject(ber_context, control_value)
 77    raw_cookie = ber_seq[1]
 78    cookie = raw_cookie.value
 79    return cookie
 80
 81
 82def onError(err):
 83    err.printDetailedTraceback(file=sys.stderr)
 84
 85
 86def main(reactor, args):
 87    endpoint_str = args.endpoint
 88    e = clientFromString(reactor, endpoint_str)
 89    d = connectProtocol(e, LDAPClient())
 90    d.addCallback(onConnect, args)
 91    d.addErrback(onError)
 92    return d
 93
 94
 95if __name__ == "__main__":
 96    parser = argparse.ArgumentParser(description="AD LDAP demo.")
 97    parser.add_argument(
 98        "endpoint",
 99        action="store",
100        help="The Active Directory service endpoint. See "
101        "https://twistedmatrix.com/documents/current/core/howto/endpoints.html#clients",
102    )
103    parser.add_argument(
104        "bind_dn", action="store", help="The DN to BIND to the service as."
105    )
106    parser.add_argument(
107        "passwd_file",
108        action="store",
109        type=argparse.FileType("r"),
110        help="A file containing the password used to log into the service.",
111    )
112    parser.add_argument(
113        "base_dn", action="store", help="The base DN to start from when searching."
114    )
115    parser.add_argument("-f", "--filter", action="store", help="LDAP filter")
116    parser.add_argument(
117        "-p",
118        "--page-size",
119        type=int,
120        action="store",
121        default=100,
122        help="Page size (default 100).",
123    )
124    parser.add_argument(
125        "--start-tls",
126        action="store_true",
127        help="Request StartTLS after connecting to the service.",
128    )
129    args = parser.parse_args()
130    react(main, [args])

Discussion

On connecting to the LDAP service, our client establishes TLS and BINDs as a DN that has permission to perform a search. Page, cookie, and the result count are intialized before looping to process each page. Initially, a blank cookie is used in the search request. The cookie obtained from each response is used in the next request, until the cookie is blank. This signals the end of the loop.

Note how the search returns a tuple of results and controls from the LDAP response. This is because the return_controls flag of the search was set to True.

Parsing the cookie requires some BER decoding. For details on encoding of the control value, refer to RFC 2696.

Adding an LDAP Entry

Ldaptor allows your LDAP client make many different kinds of LDAP requests. In this example, a simple client connects to an LDAP service and requests adding an new entry.

Code

 1#! /usr/bin/env python
 2
 3import sys
 4
 5from twisted.internet import defer
 6from twisted.internet.endpoints import clientFromString, connectProtocol
 7from twisted.internet.task import react
 8from twisted.python import log
 9from ldaptor.protocols.ldap.ldapclient import LDAPClient
10from ldaptor.protocols import pureber, pureldap
11
12
13def entry_to_attributes(entry):
14    """
15    Convert a simple mapping to the data structures required for an
16    entry in the DIT.
17
18    Returns: (dn, attributes)
19    """
20    attributes = {}
21    dn = None
22    for prop, value in entry.items():
23        if prop == "dn":
24            dn = value
25            continue
26        attributes.setdefault(prop, set()).add(value)
27    if dn is None:
28        raise Exception("Entry needs to include key, `dn`!")
29    ldap_attributes = []
30    for attrib, values in attributes.items():
31        ldap_attribute_type = pureldap.LDAPAttributeDescription(attrib)
32        ldap_attribute_values = []
33        for value in values:
34            ldap_attribute_values.append(pureldap.LDAPAttributeValue(value))
35        ldap_values = pureber.BERSet(ldap_attribute_values)
36        ldap_attributes.append((ldap_attribute_type, ldap_values))
37    return dn, ldap_attributes
38
39
40@defer.inlineCallbacks
41def onConnect(client, entry):
42    dn, attributes = entry_to_attributes(entry)
43    op = pureldap.LDAPAddRequest(entry=dn, attributes=attributes)
44    response = yield client.send(op)
45    if response.resultCode != 0:
46        log.err(
47            "DIT reported error code {}: {}".format(
48                response.resultCode, response.errorMessage
49            )
50        )
51
52
53def onError(err, reactor):
54    if reactor.running:
55        log.err(err)
56        reactor.stop()
57
58
59def main(reactor):
60    log.startLogging(sys.stdout)
61    entry = {
62        "dn": "gn=Jane+sn=Doe,ou=people,dc=example,dc=fr",
63        "c": "US",
64        "gn": "Jane",
65        "l": "Philadelphia",
66        "objectClass": "addressbookPerson",
67        "postalAddress": "230",
68        "postalCode": "314159",
69        "sn": "Doe",
70        "st": "PA",
71        "street": "Mobius Strip",
72        "userPassword": "terces",
73    }
74    endpoint_str = "tcp:host=localhost:port=8080"
75    e = clientFromString(reactor, endpoint_str)
76    d = connectProtocol(e, LDAPClient())
77    d.addCallback(onConnect, entry)
78    d.addErrback(onError, reactor)
79    return d
80
81
82react(main)

Discussion

Once again, the twisted.internet.task.react() function is used to call the main() function of the client. When main() is called, we create a client endpoint from a string description and the reactor. twisted.internet.endpoints.connectProtocol() is used to make a one-time connection to a LDAP directory listening on the local host, port 8080.

When the deferred returned from that function fires, the connection has been established and the client protocol instance is passed to the onConnect() callback, along with our entry.

In this case we use a simple Python dictionary to model our entry. We need to transform this into a data structure that ldaptor.protocols.pureldap.LDAPAddRequest can use. Once we’ve created the request, it is relatively simple to send it to the directory service with a call to the send() method of our client. The response will indicate either success or failure.