Python LDAP authentication with Microsoft Active Directory

For a school project, we have to implement LDAP authentication in edX. edX is buildt on Django and Python, so I decided to explore how to implement LDAP with Python.

I’m not a Microsoft fan, but to mirror the deployment set-up, we decided to use Microsoft Server with Active Directory.

An example of how it will work

The Microsoft Server

I grabbed a copy of Microsoft Server 2012 from DreamSpark/OnTheHub for free (yay student benefits!) and after some struggling, managed to get it running on KVM.

Install needed roles

First, install the Active Directory Domain Services role:

Add the Active Directory Domain Services role to this Windows server
Click on install, and it’s go time!

After installing the role, promote the server to the domain controller:

Click on the notification to start the promotion wizard
Create a new forest. Since this is a test server, I’ll use a .local domain.

I did not set the server to provide DNS (under Domain Controller Options). I’ll set a static IP later, for now, I’ll just use the provided dynamic IP.

I still had to install the VirtIO Ethernet driver, you can find this on the VirtIO drivers iso that you probably used to install the HDD VirtIO drivers during the installation. Right-click on NETKVM.INF and choose Install.

Note that you need not install Active Directory Lightweight Directory Services; you can query Active Directory just fine.

Testing it out

If you’re on a debian machine, you can use sudo apt-get install python-ldap to install the Python LDAP package. That way, you can import ldapfrom the console anywhere. In the next section, we will install python-ldapin a virtual environment.

To try it out, grab the current IP of the Windows server (ipconfig /all in cmd.exe) and try this:

$ python
Python 2.7.9 (default, Mar  1 2015, 12:57:24) 
[GCC 4.9.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import ldap
>>> conn = ldap.initialize('ldap://192.168.122.64') # the IP address of the Windows Server
>>> conn.protocol_version = 3
>>> conn.set_option(ldap.OPT_REFERRALS, 0)

Now try to authenticate:

>>> conn.simple_bind_s('[email protected]', 'yourpassword') # an account to test
(97, [], 2, [])

This is what happens when specifying wrong credentials:

>>> conn.simple_bind_s('[email protected]', 'wrongpassword')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 208, in simple_bind_s
    resp_type, resp_data, resp_msgid, resp_ctrls = self.result3(msgid,all=1,timeout=self.timeout)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 469, in result3
    resp_ctrl_classes=resp_ctrl_classes
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 476, in result4
    ldap_result = self._ldap_call(self._l.result4,msgid,all,timeout,add_ctrls,add_intermediates,add_extop)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 99, in _ldap_call
    result = func(*args,**kwargs)
ldap.INVALID_CREDENTIALS: {'info': '80090308: LdapErr: DSID-0C0903C5, comment: AcceptSecurityContext error, data 52e, v23f0', 'desc': 'Invalid credentials'}

This was surprisingly easy so far! Let’s see how we can use more of the Python LDAP API.

Using Python’s LDAP module

Getting set up

We’ll start a new project for this using virtualenv. You will need to install libldap2-dev and libsasl2-dev to install python-ldap, see this answer for more information.

$ virtualenv python-ldap # create a new virtual environment
$ cd python-ldap
$ . bin/activate # activate the virtual environment
$ sudo apt-get install libldap2-dev libsasl2-dev # headers needed for installing python-ldap
$ pip install python-ldap

A test script

Create a simple test script to verify LDAP still works from Python (it should):

#!/usr/bin/env python
import ldap
def authenticate(address, username, password):
    conn = ldap.initialize('ldap://' + address)
    conn.protocol_version = 3
    conn.set_option(ldap.OPT_REFERRALS, 0)
    return conn.simple_bind_s(username, password)

Try it from the interpreter using import test_ldap and test_ldap.authenticate():

$ python
Python 2.7.9 (default, Mar  1 2015, 12:57:24) 
[GCC 4.9.2] on linux2
Type "help", "copyright", "credits" or "license" for more information.
>>> import test_ldap
>>> test_ldap.authenticate('192.168.122.64', '[email protected]', 'password')

Creating a new user account

Let’s try creating a new user account. On the server, go to Active Directory Users and Computers, then go to Users. Right-click, choose New > User

Filling the details to create a new user

Observe that this works fine:

>>> test_ldap.authenticate('192.168.122.64', '[email protected]', 'password')
(97, [], 1, [])

We’ll use the John Doe and the Jane Doe accounts (see the next section) for testing.

Creating an organizational unit

Create a new organizational unit. I’ll call mine Students.

Create a new OU named Students

Now create a new user within that account. I’ll call her Jane Doe. We’ll use her to test how to bind to users in OUs.

Right-click on the OU and choose New > User
Fill in the details for the new user

Exploring the API

Let’s look at some commonly used methods and where to find documentation

Resources

These are some of the resources I found helpful in exploring python-ldap:

  • The python-ldap site
  • The online python-ldap docs
  • The python-ldap tag on StackOverflow
  • The Python LDAP Applications series on PacktPub (part 2, part 3, part 4)
  • Python LDAP samples by Grotan

Asynchronous methods

Methods not ending in _s are asynchronous methods. Those that do end in _s are synchronous.

ldap.initialize()

The first method you need to use is ldap.initialize. It takes one parameter, the LDAP URL to connect to:

>>> ldap.initialize('ldap://192.168.100.50')
<ldap.ldapobject.SimpleLDAPObject instance at 0x7f6e626d9a70>

This returns a SimpleLDAPObject instance. We want to save this in a variable so we can do something with it.

>>> con = ldap.initialize('ldap://192.168.122.64')
>>>

Be aware that initializing does not connect to the server yet, you won’t get any errors if you specify a server that doesn’t exist (you will once you try to bind to it).

ldap.*bind()

These methods are used to bind to a server. The methods are bindbind_ssasl_interactive_bind_ssimple_bind and simple_bind_s.

You won’t ever need to use bind and bind_s, since only simply authentication is supported at the moment. You can use bind/bind_s, but you’d have to provide ldap.AUTH_SIMPLE as the third parameter all the time.

For sasl_interactive_bind_s, which I won’t cover, you can look here.

I’ll only cover simple_bind_s here. As mentioned before, simple_bind is the same, but asynchronous. This is what the documentation says about it:

LDAPObject.simple_bind_s([who=''[, cred='']]) -> None
After an LDAP object is created, and before any other operations can be attempted over the connection, a bind operation must be performed.

simple_bind_s takes two parameters: who, the username, and cred, the password.

>>> con.simple_bind_s('[email protected]', 'password')
(97, [], 4, [])
>>> con.simple_bind_s('[email protected]', 'wrongpassword')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 208, in simple_bind_s
    resp_type, resp_data, resp_msgid, resp_ctrls = self.result3(msgid,all=1,timeout=self.timeout)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 469, in result3
    resp_ctrl_classes=resp_ctrl_classes
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 476, in result4
    ldap_result = self._ldap_call(self._l.result4,msgid,all,timeout,add_ctrls,add_intermediates,add_extop)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 99, in _ldap_call
    result = func(*args,**kwargs)
ldap.INVALID_CREDENTIALS: {'info': '80090308: LdapErr: DSID-0C0903C5, comment: AcceptSecurityContext error, data 52e, v23f0', 'desc': 'Invalid credentials'}

We see that binding successfully returns a weird tuple, while providing a wrong password throws an exception. Looking into the source shows that it returns this:

return resp_type, resp_data, resp_msgid, resp_ctrls

…That doesn’t help a whole lot. But here’s what I figured out:

  • resp_type: the result type
  • resp_data: data returned by the LDAP server
  • resp_msgid: an identifier, this increases every message. In this case, it’s 4, the next message will have a resp_msgid of 5. This is important for asynchronous methods, as we’ll see later
  • resp_ctrls: no idea.

Microsoft’s LDAP Error Codes says 0x61 (97) means The referral limit was exceeded. but RFC 2251 says it should be treated as an unknown error code, as it’s not in the range.

This post on the mailing list sheds some light on the result type:

The 97 is not the LDAP result code. It's the result type ldap.RES_BIND.
Normally you don't have to look at the results returned by
LDAPObject.simple_bind_s() (unless you want to extract the bind response
controls).
If the LDAP result code is not 0 the accompanying exception is raised like
ldap.INVALID_CREDENTIALS in your example.

So exceptions. For a wrong username or password, ldap.INVALID_CREDENTIALS will be raised. Another common one is ldap.SERVER_DOWN, which happened to me when I hadn’t used the connection in a while on the REPL. Re-initializing the connection did the trick. So trying to bind to the server should look like this (adapted from here):

try:
    l.simple_bind_s('[email protected]', 'password')
except ldap.INVALID_CREDENTIALS:
    print("Your username or password is incorrect.")
except ldap.SERVER_DOWN:
    print("The server appears to be down.")
except ldap.LDAPError, e:
    if type(e.message) == dict and e.message.has_key('desc'):
        print e.message['desc']
    else: 
        print e

If we don’t receive any messages, we know that we bound (thus authenticated) successfully!

ldap.unbind*()

Unbinding will disconnect from the LDAP server and free up resources. It will also mark the LDAPObject invalid, and all new operations and it will fail. Yes, that includes new bindings:

>>> con.unbind_s()
>>> con.simple_bind_s('[email protected]', '123')
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 207, in simple_bind_s
    msgid = self.simple_bind(who,cred,serverctrls,clientctrls)
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 201, in simple_bind
    return self._ldap_call(self._l.simple_bind,who,cred,RequestControlTuples(serverctrls),RequestControlTuples(clientctrls))
  File "/usr/lib/python2.7/dist-packages/ldap/ldapobject.py", line 99, in _ldap_call
    result = func(*args,**kwargs)
ldap.LDAPError: LDAP connection invalid

You have to initialize the server again to bind again.

Final script

#!/usr/bin/env python
import ldap
def authenticate(address, username, password):
    conn = ldap.initialize('ldap://' + address)
    conn.protocol_version = 3
    conn.set_option(ldap.OPT_REFERRALS, 0)
    try:
        result = conn.simple_bind_s(username, password)
    except ldap.INVALID_CREDENTIALS:
        return "Invalid credentials"
    except ldap.SERVER_DOWN:
        return "Server down"
    except ldap.LDAPError, e:
        if type(e.message) == dict and e.message.has_key('desc'):
            return "Other LDAP error: " + e.message['desc']
        else: 
            return "Other LDAP error: " + e
    finally:
        conn.unbind_s()
    return "Succesfully authenticated"

Conclusion

The final product

This post was an exploration into Python, Active Directory, and if they work together. Turns out they do, without a lot of configuration! I hope to use this in a future Django project. I was only really interested in authentication, but if you want to try more things (such as searching, modifying passwords, …) you can refer to the Resources section above. For example, searching in the Students OU we created earlier:

>>> import ldap
>>> import ldif
>>> ldif_writer = ldif.LDIFWriter(sys.stdout)
>>> basedn = = "OU=students,DC=mooctest,DC=local"
>>> results = con.search_s(basedn,ldap.SCOPE_SUBTREE,"(cn=*)")
>>> for dn,entry in results:
...     ldif_writer.unparse(dn,entry)
... 
dn: CN=Jane Doe,OU=Students,DC=mooctest,DC=local
accountExpires: 9223372036854775807
badPasswordTime: 130723115748598376
badPwdCount: 1
cn: Jane Doe
codePage: 0
countryCode: 0
dSCorePropagationData: 16010101000000.0Z
displayName: Jane Doe
distinguishedName: CN=Jane Doe,OU=Students,DC=mooctest,DC=local
givenName: Jane
instanceType: 4
lastLogoff: 0
lastLogon: 0
lastLogonTimestamp: 130723115727192242
logonCount: 0
name: Jane Doe
objectCategory: CN=Person,CN=Schema,CN=Configuration,DC=mooctest,DC=local
objectClass: top
objectClass: person
objectClass: organizationalPerson
objectClass: user
objectGUID:: E8uawUM+DkyLzjIQE5CSHQ==
objectSid:: AQUAAAAAAAUVAAAAyQ9OG3xBa/Vl4s3bVQQAAA==
primaryGroupID: 513
pwdLastSet: 130723115588128828
sAMAccountName: jane-doe
sAMAccountType: 805306368
sn: Doe
uSNChanged: 20571
uSNCreated: 20565
userAccountControl: 512