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
38 changes: 38 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,9 +135,47 @@ 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.

## 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 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'
```

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.
122 changes: 99 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,89 @@ 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.
"""
)

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.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

@gen.coroutine
def authenticate(self, handler, data):
username = data['username']
Expand All @@ -161,7 +249,7 @@ def getConnection(userdn, username, password):
username=username,
userdn=userdn
))
conn = ldap3.Connection(server, user=userdn, password=password)
conn = ldap3.Connection(server, user=escape_filter_chars(userdn), password=password)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaping filter chars from userdn can make the valid userdn invalid, e.g. escaping "(" or ")" which are valid characters for this parameter.

Copy link
Contributor Author

@mateuszboryn mateuszboryn Mar 9, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I had problem with ldap3.Connection() for non-escaped '(' and ')'. At least for Active Directory - I don't know how it would work for other implementations.
In my case ldap3.Connection().bind() failed with error message saying claiming that authentication failed when user parameter had '(' or ')' in it.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fascinating. For us it was the inverse - we had to remove the escaping because the escaped parentheses caused problems. We're using AD, too.

According to https://social.technet.microsoft.com/wiki/contents/articles/5312.active-directory-characters-to-escape.aspx '(' and ')' do not belong to the characters to be escaped.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For us it requires to be without the "escape_filter_chars()" function. We have usernames that have a dash in them, like foo-bar. Does not work with the escape wrapper, but does work without it.

return conn

# Protect against invalid usernames as well as LDAP injection attacks
Expand All @@ -173,13 +261,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 @@ -190,29 +283,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 @@ -221,7 +297,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