Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

User CN name lookup with specific query #32

Merged
merged 9 commits into from
Sep 27, 2017
Merged
47 changes: 47 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,13 +137,59 @@ c.LDAPAuthenticator.user_attribute = 'sAMAccountName'
c.LDAPAuthenticator.user_attribute = 'uid'
```

#### `LDAPAuthenticator.lookup_dn_search_filter` ####

How to query LDAP for user name lookup, if `lookup_dn` is set to True.
Default value ``'({login_attr}={login})'` should be good enough for most use cases.


#### `LDAPAuthenticator.lookup_dn_search_user`, `LDAPAuthenticator.lookup_dn_search_password` ####

Technical account for user lookup, if `lookup_dn` is set to True.
If both lookup_dn_search_user and lookup_dn_search_password are None, then anonymous LDAP query will be done.


#### `LDAPAuthenticator.lookup_dn_user_dn_attribute` ####

Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True.
See `user_search_base` for info on how this attribute is used.
For most LDAP servers, this is username. For Active Directory, it is cn.

#### `LDAPAuthenticator.escape_userdn` ####

If set to True, escape special chars in userdn when authenticating in LDAP.
On some LDAP servers, when userdn contains chars like '(', ')', '\' authentication may fail when those chars
are not escaped.

## Compatibility ##

This has been tested against an OpenLDAP server, with the client
running Python 3.4. Verifications of this code working well with
other LDAP setups are welcome, as are bug reports and patches to make
it work with other LDAP setups!


## Active Directory integration ##

Please use following options for AD integration. This is useful especially in two cases:
* LDAP Search requires valid user account in order to query user database
* DN does not contain login but some other field, like CN (actual login is present in sAMAccountName, and we need to lookup CN)

```python
c.LDAPAuthenticator.lookup_dn = True
c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})'
c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account'
c.LDAPAuthenticator.lookup_dn_search_password = 'secret'
c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org'
c.LDAPAuthenticator.user_attribute = 'sAMAccountName'
c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn'
c.LDAPAuthenticator.escape_userdn = False
```

In setup above, first LDAP will be searched (with account ldap_search_user_technical_account) for users that have sAMAccountName=login
Then DN will be constructed using found CN value.


## Configuration note on local user creation

Currently, local user creation by the LDAPAuthenticator is unsupported as
Expand All @@ -161,3 +207,4 @@ JupyterHub create local accounts using the LDAPAuthenticator.

Issue [#19](https://github.com/jupyterhub/ldapauthenticator/issues/19) provides
additional discussion on local user creation.

139 changes: 116 additions & 23 deletions ldapauthenticator/ldapauthenticator.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ldap3
from ldap3.utils.conv import escape_filter_chars
import re

from jupyterhub.auth import Authenticator
Expand Down Expand Up @@ -126,8 +127,12 @@ def _server_port_default(self):

```
c.LDAPAuthenticator.lookup_dn = True
c.LDAPAuthenticator.lookup_dn_search_filter = '({login_attr}={login})'
c.LDAPAuthenticator.lookup_dn_search_user = 'ldap_search_user_technical_account'
c.LDAPAuthenticator.lookup_dn_search_password = 'secret'
c.LDAPAuthenticator.user_search_base = 'ou=people,dc=wikimedia,dc=org'
c.LDAPAuthenticator.user_attribute = 'uid'
c.LDAPAuthenticator.user_attribute = 'sAMAccountName'
c.LDAPAuthenticator.lookup_dn_user_dn_attribute = 'cn'
```
"""
)
Expand All @@ -146,6 +151,106 @@ def _server_port_default(self):
"""
)

lookup_dn_search_filter = Unicode(
config=True,
default_value='({login_attr}={login})',
allow_none=True,
help="""
How to query LDAP for user name lookup, if `lookup_dn` is set to True.
"""
)

lookup_dn_search_user = Unicode(
config=True,
default_value=None,
allow_none=True,
help="""
Technical account for user lookup, if `lookup_dn` is set to True.

If both lookup_dn_search_user and lookup_dn_search_password are None, then anonymous LDAP query will be done.
"""
)

lookup_dn_search_password = Unicode(
config=True,
default_value=None,
allow_none=True,
help="""
Technical account for user lookup, if `lookup_dn` is set to True.
"""
)

lookup_dn_user_dn_attribute = Unicode(
config=True,
default_value=None,
allow_none=True,
help="""
Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True.

See `user_search_base` for info on how this attribute is used.

For most LDAP servers, this is username. For Active Directory, it is cn.
"""
)

escape_userdn = Bool(
False,
config=True,
help="""
If set to True, escape special chars in userdn when authenticating in LDAP.

On some LDAP servers, when userdn contains chars like '(', ')', '\' authentication may fail when those chars
are not escaped.
"""
)

def resolve_username(self, username_supplied_by_user):
if self.lookup_dn:
server = ldap3.Server(
self.server_address,
port=self.server_port,
use_ssl=self.use_ssl
)

search_filter = self.lookup_dn_search_filter.format(
login_attr=self.user_attribute,
login=username_supplied_by_user
)
self.log.debug(
"Looking up user with search_base={search_base}, search_filter='{search_filter}', attributes={attributes}".format(
search_base=self.user_search_base,
search_filter=search_filter,
attributes=self.user_attribute
)
)

conn = ldap3.Connection(server, user=self.escape_userdn_if_needed(self.lookup_dn_search_user), password=self.lookup_dn_search_password)
is_bound = conn.bind()
if not is_bound:
self.log.warn("Can't connect to LDAP")
return None

conn.search(
search_base=self.user_search_base,
search_scope=ldap3.SUBTREE,
search_filter=search_filter,
attributes=[self.lookup_dn_user_dn_attribute]
)

if len(conn.response) == 0 or 'attributes' not in conn.response[0].keys():
self.log.warn('username:%s No such user entry found when looking up with attribute %s', username_supplied_by_user,
self.user_attribute)
return None
return conn.response[0]['attributes'][self.lookup_dn_user_dn_attribute]
else:
return username_supplied_by_user

def escape_userdn_if_needed(self, userdn):
if self.escape_userdn:
return escape_filter_chars(userdn)
else:
return userdn

search_filter = Unicode(
config=True,
help="LDAP3 Search Filter whose results are allowed access"
Expand All @@ -172,7 +277,7 @@ def getConnection(userdn, username, password):
username=username,
userdn=userdn
))
conn = ldap3.Connection(server, user=userdn, password=password)
conn = ldap3.Connection(server, user=self.escape_userdn_if_needed(userdn), password=password)
return conn

# Protect against invalid usernames as well as LDAP injection attacks
Expand All @@ -184,13 +289,18 @@ def getConnection(userdn, username, password):
if password is None or password.strip() == '':
self.log.warn('username:%s Login denied for blank password', username)
return None

isBound = False
self.log.debug("TYPE= '%s'",isinstance(self.bind_dn_template, list))

resolved_username = self.resolve_username(username)
if resolved_username is None:
return None

# In case, there are multiple binding templates
if isinstance(self.bind_dn_template, list):
for dn in self.bind_dn_template:
userdn = dn.format(username=username)
userdn = dn.format(username=resolved_username)
conn = getConnection(userdn, username, password)
isBound = conn.bind()
self.log.debug('Status of user bind {username} with {userdn} : {isBound}'.format(
Expand All @@ -201,29 +311,12 @@ def getConnection(userdn, username, password):
if isBound:
break
else:
userdn = self.bind_dn_template.format(username=username)
userdn = self.bind_dn_template.format(username=resolved_username)
conn = getConnection(userdn, username, password)
isBound = conn.bind()

if isBound:
if self.allowed_groups:
if self.lookup_dn:
# In some cases, like AD, we don't bind with the DN, and need to discover it.
conn.search(
search_base=self.user_search_base,
search_scope=ldap3.SUBTREE,
search_filter='({userattr}={username})'.format(
userattr=self.user_attribute,
username=username
),
attributes=[self.user_attribute]
)

if len(conn.response) == 0:
self.log.warn('username:%s No such user entry found when looking up with attribute %s', username, self.user_attribute)
return None
userdn = conn.response[0]['dn']

self.log.debug('username:%s Using dn %s', username, userdn)
for group in self.allowed_groups:
groupfilter = (
Expand All @@ -232,7 +325,7 @@ def getConnection(userdn, username, password):
'(uniqueMember={userdn})'
'(memberUid={uid})'
')'
).format(userdn=userdn, uid=username)
).format(userdn=escape_filter_chars(userdn), uid=escape_filter_chars(username))
groupattributes = ['member', 'uniqueMember', 'memberUid']
if conn.search(
group,
Expand Down