Search code examples
ldappam

Python Authentication: PAM or LDAP bind() vs. LDAP {SSHA} check?


I'm writing a fairly simple internal application (currently prototyped in Bottle) which will send notification for incidents and change management events to an internal mailing list while forcing these notices to conform to a couple of standard templates (and ensuring, through the Python Jira API that the required issue references exist and are in the appropriate state).

Naturally we require that users authenticate to my application before I send messages which will be attributed to them. We use LDAP and all password hashes are stored in {SSHA} format.

I've found at least three different ways to perform the authentication:

  • bind to LDAP with a service account with sufficient LDAP ACIs to fetch password hashes (same as our /etc/sssd/sssd.conf systems level authentication), use that to find the dn and extract the 'userPassword' attribute; then validate that against the proposed password using hashlib
  • bind to LDAP with a service account with limited search privileges, use that to find the user's dn and then attempt to bind to LDAP using that dn and the users proposed password
  • Use the authenticate() functions from either the simplepam (pure Python/ctypes wrapper) or python-pam modules

Here's code which seems to correctly implement the first of these:

#!python
import hashlib
import ConfigParser, os
from base64 import encodestring as encode
from base64 import decodestring as decode

import ldap

config = ConfigParser.ConfigParser()
config.read(os.path.expanduser('~/.creds.ini'))
uid = config.get('LDAP', 'uid')
pwd = config.get('LDAP', 'pwd')
svr = config.get('LDAP', 'svr')
bdn = config.get('LDAP', 'bdn')

ld = ldap.initialize(svr)
ld.protocol_version = ldap.VERSION3
ld.simple_bind_s(uid, pwd)

def chk(prop, pw):
    pw=decode(pw[6:])         # Base64 decode after stripping off {SSHA}
    digest = pw[:20]          # Split digest/hash of PW from salt
    salt = pw[20:]            # Extract salt
    chk = hashlib.sha1(prop)  # Hash the string presented
    chk.update(salt)          # Salt to taste:
    return chk.digest() == digest

if __name__ == '__main__':
    import sys
    from getpass import getpass
    max_attempts = 3

    if len(sys.argv) < 2:
        print 'Must supply username against which to authenticate'
        sys.exit(127)
    name = sys.argv[1]

    user_dn = ld.search_s(bdn, ldap.SCOPE_SUBTREE, '(uid=%s)' % name)
    if len(user_dn) < 1:
        print 'No DN found for %s' % name
        sys.exit(126)
    pw = user_dn[0][1].get('userPassword', [''])[0]

    exit_value = 1
    attempts = 0
    while attempts < max_attempts:
        prop = getpass('Password: ')
        if chk(prop, pw):
            print 'Authentication successful'
            exit_value = 0
            break
        else:
            print 'Authentication failed'
        attempts += 1
    else:
        print 'Maximum retries exceeded'
    sys.exit(exit_value)

This seems to work (assuming we have the appropriate values in our .creds.ini).

Here's a bit of code implementing the second option:

#!python
# ...
### Same ConfigParser and LDAP initialization as before
# ...

def chk(prop, dn):
    chk = ldap.initialize(svr)
    chk.protocol_version = ldap.VERSION3
    try:
        chk.simple_bind_s(dn, prop)
    except ldap.INVALID_CREDENTIALS:
        return False
    chk.unbind()
    return True

if __name__ == '__main__':
    import sys
    from getpass import getpass
    max_attempts = 3


    if len(sys.argv) < 2:
        print 'Must supply username against which to authenticate'
        sys.exit(127)
    name = sys.argv[1]

    user_dn = ld.search_s(bdn, ldap.SCOPE_SUBTREE, '(uid=%s)' % name)
    if len(user_dn) < 1:
        print 'No distinguished name (DN) found for %s' % name
        sys.exit(126)

    dn = user_dn[0][0]

    exit_value = 1
    attempts = 0
    while attempts < max_attempts:
        prop = getpass('Password: ')
        if chk(prop, dn):
            print 'Authentication successful'
            exit_value = 0
            break
        else:
            print 'Authentication failed'
        attempts += 1
    else:
        print 'Maximum retries exceeded'
    sys.exit(exit_value)

Not shown here but I also tested that I can continue to use the ld LDAP connection independently of the transient chk LDAP object. So my long-running web service can keep re-using the one connection.

The last options are almost identical regardless of which of the two PAM modules I use. Here's an example using python-pam:

#!/usr/bin/env python
import pam

pam_conn = pam.pam()

def chk(prop, name):
    return pam_conn.authenticate(name, prop)

if __name__ == '__main__':
    import sys
    from getpass import getpass
    max_attempts = 3


    if len(sys.argv) < 2:
        print 'Must supply username against which to authenticate'
        sys.exit(127)
    name = sys.argv[1]

    exit_value = 1
    attempts = 0
    while attempts < max_attempts:
        prop = getpass('Password: ')
        if chk(prop, name):
            print 'Authentication successful'
            exit_value = 0
            break
        else:
            print 'Authentication failed'
        attempts += 1
    else:
        print 'Maximum retries exceeded'
    sys.exit(exit_value)

My question is: which of these should I use. Are any of them particularly less secure than the others? Is there any consensus on "best practices" for this?


Solution

  • Definitely use a bind as the userDN and password presented by the user.

    "bind to LDAP with a service account with limited search privileges, use that to find the user's dn and then attempt to bind to LDAP using that dn and the users proposed password"

    Sorry, I do not know how the "Authenticate() functions" behave.

    Not binding as the userDN, may bypass the builtin server functions that apply to password policy or account restrictions and intruder detection.

    -jim