From 015f075871bb3ef41a5cd600e1e3825af59ddabc Mon Sep 17 00:00:00 2001 From: Mateusz Boryn Date: Wed, 1 Feb 2017 13:03:20 +0100 Subject: [PATCH 1/6] User CN name lookup with specific query and (non)anonymous ActiveDirectory search account. --- ldapauthenticator/ldapauthenticator.py | 111 ++++++++++++++++++++++++- 1 file changed, 108 insertions(+), 3 deletions(-) diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index c0c1baa..c7f5dfc 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -3,7 +3,7 @@ from jupyterhub.auth import Authenticator from tornado import gen -from traitlets import Unicode, Int, Bool, List +from traitlets import Unicode, Int, Bool, Union, List class LDAPAuthenticator(Authenticator): @@ -146,6 +146,111 @@ def _server_port_default(self): """ ) + lookup_by_login = Bool( + default_value=False, + config=True, + help=""" + If set to True, then execute LDAP query to get proper DN. + + This can be used to translate sAMAccountName to CN in AciveDirectory using custom query. + If lookup_by_login_search_user and lookup_by_login_search_password then query is performed using specified user. + This is required if ActiveDirectory has disabled anonymous search. + + Example setup: + + c.LDAPAuthenticator.lookup_by_login = True + c.LDAPAuthenticator.lookup_by_login_search_base = 'DC=company,DC=com' + c.LDAPAuthenticator.lookup_by_login_search_filter = '({login_attr}={login})' + c.LDAPAuthenticator.lookup_by_login_search_user = 'ldap_search_user' + c.LDAPAuthenticator.lookup_by_login_search_password = 'secret' + c.LDAPAuthenticator.lookup_by_login_ldap_login_attribute = 'sAMAccountName' + c.LDAPAuthenticator.lookup_by_login_ldap_username_attribute = 'CN' + """ + ) + + lookup_by_login_search_base = Unicode( + config=True, + default_value=None, + allow_none=True, + help="""""" + ) + + lookup_by_login_search_filter = Unicode( + config=True, + default_value='({login_attr}={login})', + allow_none=True, + help="""""" + ) + + lookup_by_login_search_user = Unicode( + config=True, + default_value=None, + allow_none=True, + help="""""" + ) + + lookup_by_login_search_password = Unicode( + config=True, + default_value=None, + allow_none=True, + help="""""" + ) + + lookup_by_login_ldap_login_attribute = Unicode( + config=True, + default_value=None, + allow_none=True, + help="""""" + ) + + lookup_by_login_ldap_username_attribute = Unicode( + config=True, + default_value=None, + allow_none=True, + help="""""" + ) + + def resolve_username(self, username_supplied_by_user): + if self.lookup_by_login: + server = ldap3.Server( + self.server_address, + port=self.server_port, + use_ssl=self.use_ssl + ) + + search_filter = self.lookup_by_login_search_filter.format( + login_attr=self.lookup_by_login_ldap_login_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.lookup_by_login_search_base, + search_filter=search_filter, + attributes=self.lookup_by_login_ldap_username_attribute + ) + ) + + conn = ldap3.Connection(server, user=self.lookup_by_login_search_user, password=self.lookup_by_login_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.lookup_by_login_search_base, + search_scope=ldap3.SUBTREE, + search_filter=search_filter, + attributes=[self.lookup_by_login_ldap_username_attribute] + ) + + if len(conn.response) == 0: + self.log.warn('username:%s No such user entry found when looking up with attribute %s', username_supplied_by_user, + self.lookup_by_login_ldap_login_attribute) + return None + return conn.response[0]['attributes'][self.lookup_by_login_ldap_username_attribute] + else: + return username_supplied_by_user + @gen.coroutine def authenticate(self, handler, data): username = data['username'] @@ -179,7 +284,7 @@ def getConnection(userdn, username, password): # 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=self.resolve_username(username)) conn = getConnection(userdn, username, password) isBound = conn.bind() self.log.debug('Status of user bind {username} with {userdn} : {isBound}'.format( @@ -190,7 +295,7 @@ def getConnection(userdn, username, password): if isBound: break else: - userdn = self.bind_dn_template.format(username=username) + userdn = self.bind_dn_template.format(username=self.resolve_username(username)) conn = getConnection(userdn, username, password) isBound = conn.bind() From 1a752c985dbe37ebf497cf1d959db04b99edb79b Mon Sep 17 00:00:00 2001 From: Mateusz Boryn Date: Wed, 1 Feb 2017 17:15:30 +0100 Subject: [PATCH 2/6] Fixed some issues with allowed_groups setting --- README.md | 38 ++++++++ ldapauthenticator/ldapauthenticator.py | 116 +++++++++---------------- 2 files changed, 81 insertions(+), 73 deletions(-) diff --git a/README.md b/README.md index 2fe5615..aa86e74 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index c7f5dfc..1e630a9 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -126,8 +126,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' ``` """ ) @@ -146,108 +150,86 @@ def _server_port_default(self): """ ) - lookup_by_login = Bool( - default_value=False, + lookup_dn_search_filter = Unicode( config=True, + default_value='({login_attr}={login})', + allow_none=True, help=""" - If set to True, then execute LDAP query to get proper DN. - - This can be used to translate sAMAccountName to CN in AciveDirectory using custom query. - If lookup_by_login_search_user and lookup_by_login_search_password then query is performed using specified user. - This is required if ActiveDirectory has disabled anonymous search. - - Example setup: - - c.LDAPAuthenticator.lookup_by_login = True - c.LDAPAuthenticator.lookup_by_login_search_base = 'DC=company,DC=com' - c.LDAPAuthenticator.lookup_by_login_search_filter = '({login_attr}={login})' - c.LDAPAuthenticator.lookup_by_login_search_user = 'ldap_search_user' - c.LDAPAuthenticator.lookup_by_login_search_password = 'secret' - c.LDAPAuthenticator.lookup_by_login_ldap_login_attribute = 'sAMAccountName' - c.LDAPAuthenticator.lookup_by_login_ldap_username_attribute = 'CN' + How to query LDAP for user name lookup, if `lookup_dn` is set to True. """ ) - lookup_by_login_search_base = Unicode( + lookup_dn_search_user = Unicode( config=True, default_value=None, allow_none=True, - help="""""" - ) + help=""" + Technical account for user lookup, if `lookup_dn` is set to True. - lookup_by_login_search_filter = Unicode( - config=True, - default_value='({login_attr}={login})', - allow_none=True, - help="""""" + If both lookup_dn_search_user and lookup_dn_search_password are None, then anonymous LDAP query will be done. + """ ) - lookup_by_login_search_user = Unicode( + lookup_dn_search_password = Unicode( config=True, default_value=None, allow_none=True, - help="""""" + help=""" + Technical account for user lookup, if `lookup_dn` is set to True. + """ ) - lookup_by_login_search_password = Unicode( + lookup_dn_user_dn_attribute = Unicode( config=True, default_value=None, allow_none=True, - help="""""" - ) + help=""" + Attribute containing user's name needed for building DN string, if `lookup_dn` is set to True. - lookup_by_login_ldap_login_attribute = Unicode( - config=True, - default_value=None, - allow_none=True, - help="""""" - ) + See `user_search_base` for info on how this attribute is used. - lookup_by_login_ldap_username_attribute = Unicode( - config=True, - default_value=None, - allow_none=True, - help="""""" + For most LDAP servers, this is username. For Active Directory, it is cn. + """ ) def resolve_username(self, username_supplied_by_user): - if self.lookup_by_login: + if self.lookup_dn: server = ldap3.Server( self.server_address, port=self.server_port, use_ssl=self.use_ssl ) - search_filter = self.lookup_by_login_search_filter.format( - login_attr=self.lookup_by_login_ldap_login_attribute, + 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.lookup_by_login_search_base, + search_base=self.user_search_base, search_filter=search_filter, - attributes=self.lookup_by_login_ldap_username_attribute + attributes=self.user_attribute ) ) - conn = ldap3.Connection(server, user=self.lookup_by_login_search_user, password=self.lookup_by_login_search_password) + 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.lookup_by_login_search_base, + search_base=self.user_search_base, search_scope=ldap3.SUBTREE, search_filter=search_filter, - attributes=[self.lookup_by_login_ldap_username_attribute] + attributes=[self.lookup_dn_user_dn_attribute] ) - if len(conn.response) == 0: + 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.lookup_by_login_ldap_login_attribute) + self.user_attribute) return None - return conn.response[0]['attributes'][self.lookup_by_login_ldap_username_attribute] + return conn.response[0]['attributes'][self.lookup_dn_user_dn_attribute] else: return username_supplied_by_user @@ -278,13 +260,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=self.resolve_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( @@ -295,29 +282,12 @@ def getConnection(userdn, username, password): if isBound: break else: - userdn = self.bind_dn_template.format(username=self.resolve_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 = ( From 99a985f92f01c6954cb44350eb1bd21b68921443 Mon Sep 17 00:00:00 2001 From: Mateusz Boryn Date: Fri, 24 Feb 2017 16:30:39 +0100 Subject: [PATCH 3/6] escaping filter chars --- ldapauthenticator/ldapauthenticator.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index 1e630a9..c49db45 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -1,4 +1,5 @@ import ldap3 +from ldap3.utils.conv import escape_filter_chars import re from jupyterhub.auth import Authenticator @@ -296,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, From 5267dc1ad45417a1d7d010924eeef4c06a415feb Mon Sep 17 00:00:00 2001 From: Mateusz Boryn Date: Wed, 1 Mar 2017 16:09:16 +0100 Subject: [PATCH 4/6] AD error fixed when there was bracket in CN string --- ldapauthenticator/ldapauthenticator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index c49db45..ab7e176 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -249,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) return conn # Protect against invalid usernames as well as LDAP injection attacks From 17e4d86e9c48fa85229f351518523dccb98012ba Mon Sep 17 00:00:00 2001 From: Mateusz Boryn Date: Fri, 7 Apr 2017 15:19:41 +0200 Subject: [PATCH 5/6] new config option: escape user_dn --- README.md | 6 ++++++ ldapauthenticator/ldapauthenticator.py | 21 +++++++++++++++++++-- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index aa86e74..1e507d1 100644 --- a/README.md +++ b/README.md @@ -153,6 +153,12 @@ Attribute containing user's name needed for building DN string, if `lookup_dn` 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 diff --git a/ldapauthenticator/ldapauthenticator.py b/ldapauthenticator/ldapauthenticator.py index ab7e176..3a778eb 100644 --- a/ldapauthenticator/ldapauthenticator.py +++ b/ldapauthenticator/ldapauthenticator.py @@ -193,6 +193,17 @@ def _server_port_default(self): """ ) + 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( @@ -213,7 +224,7 @@ def resolve_username(self, username_supplied_by_user): ) ) - conn = ldap3.Connection(server, user=self.lookup_dn_search_user, password=self.lookup_dn_search_password) + 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") @@ -234,6 +245,12 @@ def resolve_username(self, username_supplied_by_user): 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 + @gen.coroutine def authenticate(self, handler, data): username = data['username'] @@ -249,7 +266,7 @@ def getConnection(userdn, username, password): username=username, userdn=userdn )) - conn = ldap3.Connection(server, user=escape_filter_chars(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 From ebbdfdbeaa199b08ebd00ac31c6904969080411d Mon Sep 17 00:00:00 2001 From: Mateusz Boryn Date: Fri, 7 Apr 2017 16:37:49 +0200 Subject: [PATCH 6/6] new config option: escape user_dn --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 1e507d1..229607d 100644 --- a/README.md +++ b/README.md @@ -181,6 +181,7 @@ 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