From 79d89aca2b8e6252ffc2f96f759bc3e5cffa6a0b Mon Sep 17 00:00:00 2001 From: Gaetan Delannay Date: Fri, 6 Sep 2013 16:19:56 +0200 Subject: [PATCH] [gen, shared] More work on LDAP. --- gen/__init__.py | 51 +++++++++++- gen/ldap.py | 34 -------- gen/mixins/ToolMixin.py | 168 ++++++++++++++++++++++++--------------- gen/model.py | 9 ++- shared/ldap_connector.py | 72 +++++++++++++++++ 5 files changed, 231 insertions(+), 103 deletions(-) delete mode 100644 gen/ldap.py create mode 100644 shared/ldap_connector.py diff --git a/gen/__init__.py b/gen/__init__.py index 8b0dcdf..0a1780f 100644 --- a/gen/__init__.py +++ b/gen/__init__.py @@ -368,7 +368,12 @@ class User(Model): # ------------------------------------------------------------------------------ class LdapConfig: - '''Parameters for authenticating users to an external LDAP.''' + '''Parameters for authenticating users to an LDAP server.''' + ldapAttributes = { 'loginAttribute':None, 'emailAttribute':'email', + 'fullNameAttribute':'title', + 'firstNameAttribute':'firstName', + 'lastNameAttribute':'name' } + def __init__(self): self.server = '' # Name of the LDAP server self.port = None # Port for this server. @@ -378,7 +383,17 @@ class LdapConfig: self.adminPassword = '' # LDAP attribute to use as login for authenticating users. self.loginAttribute = 'dn' # Can also be "mail", "sAMAccountName", "cn" - self.baseDn = '' # Base distinguished name where to find users. + # LDAP attributes for storing email + self.emailAttribute = None + # LDAP attribute for storing full name (first + last name) + self.fullNameAttribute = None + # Alternately, LDAP attributes for storing 1st & last names separately. + self.firstNameAttribute = None + self.lastNameAttribute = None + # LDAP classes defining the users stored in the LDAP. + self.userClasses = ('top', 'person') + self.baseDn = '' # Base DN where to find users in the LDAP. + self.scope = 'SUBTREE' # Scope of the search within self.baseDn def getServerUri(self): '''Returns the complete URI for accessing the LDAP, ie @@ -386,6 +401,37 @@ class LdapConfig: port = self.port or 389 return 'ldap://%s:%d' % (self.server, port) + def getUserFilterValues(self, login): + '''Gets the filter values required to perform a query for finding user + corresponding to p_login in the LDAP.''' + res = [(self.loginAttribute, login)] + for userClass in self.userClasses: + res.append( ('objectClass', userClass) ) + return res + + def getUserAttributes(self): + '''Gets the attributes we want to get from the LDAP for characterizing + a user.''' + res = [self.loginAttribute] + for name in self.ldapAttributes.iterkeys(): + if getattr(self, name): + res.append(getattr(self, name)) + return res + + def getUserParams(self, ldapData): + '''Formats the user-related p_ldapData retrieved from the ldap, as a + dict of params usable for creating or updating the corresponding + Appy user.''' + res = {} + for name, appyName in self.ldapAttributes.iteritems(): + if not appyName: continue + # Get the name of the attribute as known in the LDAP. + ldapName = getattr(self, name) + if not ldapName: continue + if ldapData.has_key(ldapName) and ldapData[ldapName]: + res[appyName] = ldapData[ldapName] + return res + # ------------------------------------------------------------------------------ class Config: '''If you want to specify some configuration parameters for appy.gen and @@ -397,7 +443,6 @@ class Config: class Config(appy.gen.Config): langages = ('en', 'fr') ''' - # For every language code that you specify in this list, appy.gen will # produce and maintain translation files. languages = ['en'] diff --git a/gen/ldap.py b/gen/ldap.py deleted file mode 100644 index d8f0ce0..0000000 --- a/gen/ldap.py +++ /dev/null @@ -1,34 +0,0 @@ -# ------------------------------------------------------------------------------ -try: - import ldap -except ImportError: - # For people that do not care about ldap. - ldap = None - -# ------------------------------------------------------------------------------ -def connect(serverUri, login, password): - '''Tries to connect to some LDAP server whose UIR is p_serverUri, using - p_login and p_password as credentials.''' - try: - server = ldap.initialize(serverUri) - server.simple_bind(login, password) - return True, server, None - except ldap.LDAPError, le: - return False, None, str(le) - -# ------------------------------------------------------------------------------ -def authenticate(login, password, ldapConfig, tool): - '''Tries to authenticate user p_login in the LDAP.''' - # Connect to the ldap server. - serverUri = cfg.getServerUri() - success, server, msg = connect(serverUri, cfg.adminLogin, cfg.adminPassword) - # Manage a connection error. - if not success: - tool.log('%s: connect error (%s).' % (serverUri, msg)) - return - # Do p_login and p_password correspond to a user in the LDAP? - try: - pass - except: - pass -# ------------------------------------------------------------------------------ diff --git a/gen/mixins/ToolMixin.py b/gen/mixins/ToolMixin.py index 4654ad6..fb3e4b8 100644 --- a/gen/mixins/ToolMixin.py +++ b/gen/mixins/ToolMixin.py @@ -1,8 +1,8 @@ # ------------------------------------------------------------------------------ -import os, os.path, sys, re, time, random, types +import os, os.path, sys, re, time, random, types, base64 from appy import Object import appy.gen -from appy.gen import Search, UiSearch, String, Page, ldap +from appy.gen import Search, UiSearch, String, Page from appy.gen.layout import ColumnLayout from appy.gen import utils as gutils from appy.gen.mixins import BaseMixin @@ -12,6 +12,7 @@ from appy.gen.mail import sendMail from appy.shared import mimeTypes from appy.shared import utils as sutils from appy.shared.data import languages +from appy.shared.ldap_connector import LdapConnector try: from AccessControl.ZopeSecurityPolicy import _noroles except ImportError: @@ -979,21 +980,106 @@ class ToolMixin(BaseMixin): '''Returns the encrypted version of clear p_password.''' return self.acl_users._encryptPassword(password) - def _zopeAuthenticate(self, request): - '''Performs the Zope-level authentication. Returns True if - authentication succeeds.''' - user = self.acl_users.validate(request) - return user.getUserName() != 'Anonymous User' + def getUser(self, authentify=False): + '''Gets the current user. If p_authentify is True, in addition to + finding the logged user and returning it (=identification), we check + if found credentials are valid (=authentification).''' + # I. Identify the user (=find its login and password). If identification + # fails, if we don't need to authentify the user (p_authentify is + # False), we consider that the current user is one of those special + # users: anon (corresponds to an anonymous user) or system (the + # technical user representing the system itself, running at startup or + # in batch mode). + tool = self.appy() + req = tool.request + # Try first to return the user that can be cached on the request. In + # this case, we suppose authentification has previously been done, and + # we just return the cached user. + if hasattr(req, 'user'): return req.user + login = password = None + isSpecial = False + # Ia. Identify the user from http basic authentication. + if getattr(req, '_auth', None): + # HTTP basic authentication credentials are present (used when + # connecting to the ZMI). Decode it. + creds = req._auth + if creds.lower().startswith('basic '): + try: + creds = creds.split(' ')[-1] + login, password = base64.decodestring(creds).split(':', 1) + except Exception, e: + pass + # Ib. Identify the user from the authentication cookie. + if not login: + login, password = gutils.readCookie(req) + # Ic. Identify the user from the authentication form. + if not login: + login = req.get('__ac_name', None) + password = req.get('__ac_password', None) + # Stop the identification process here if we needed to authentify the + # user: this user does not exist. + if not login and authentify: return + # Id. All the identification methods failed. So identify the user as + # "anon" or "system". + if not login and not authentify: + # If we have a real request object, it is the anonymous user. + login = (req.__class__.__name__ == 'Object') and 'system' or 'anon' + isSpecial = True + # Now, get the User instance from a query in the catalog. + user = tool.search1('User', noSecurity=True, login=login) + # It is possible that we find no user here: it happens before users + # "anon" and "system" are created, at first startup. + if not user: return + # Authentify the user if required + if authentify and not isSpecial: + if not user.checkPassword(password): + # Disable the authentication cookie. + req.RESPONSE.expireCookie('_appy_', path='/') + return + # Create an authentication cookie for this user. + gutils.writeCookie(login, password, req) + # Cache the user and some precomputed values, for performance. + req.user = user + req.userRoles = user.getRoles() + req.userLogins = user.getLogins() + req.zopeUser = user.getZopeUser() + return user def _ldapAuthenticate(self, login, password): '''Performs a LDAP-based authentication. Returns True if authentication succeeds.''' # Check if LDAP is configured. - ldapConfig = self.getProductConfig(True).ldap - if not ldapConfig: return - user = ldap.authenticate(login, password, ldapConfig, self) - if not user: return - return True + cfg = self.getProductConfig(True).ldap + if not cfg: return + # Get a connector to the LDAP server and connect to the LDAP server. + serverUri = cfg.getServerUri() + connector = LdapConnector(serverUri, tool=self) + success, msg = connector.connect(cfg.adminLogin, cfg.adminPassword) + if not success: return + # Check if the user corresponding to p_login exists in the LDAP. + filter = connector.getFilter(cfg.getUserFilterValues(login)) + params = cfg.getUserAttributes() + ldapData = connector.search(cfg.baseDn, cfg.scope, filter, params) + if not ldapData: return + # The user exists. Try to connect to the LDAP with this user in order + # to validate its password. + userConnector = LdapConnector(serverUri, tool=self) + success, msg = userConnector.connect(ldapData[0][0], password) + if not success: return + # The password is correct. We can create/update our local user + # corresponding to this LDAP user. + userParams = cfg.getUserParams(ldapData) + user = self.search1('User', noSecurity=True, login=login) + tool = self + if user: + # Update the user with fresh info about him from the LDAP + for name, value in userParams.iteritems(): + setattr(user, name, value) + user.reindex() + else: + # Create the user + user = tool.create('users', login=login, source='ldap',**userParams) + return user def performLogin(self): '''Logs the user in.''' @@ -1004,16 +1090,12 @@ class ToolMixin(BaseMixin): if jsEnabled and not cookiesEnabled: msg = self.translate('enable_cookies') return self.goto(urlBack, msg) - # Extract the login and password, and create an authentication cookie - login = rq.get('__ac_name', '') - password = rq.get('__ac_password', '') - gutils.writeCookie(login, password, rq) - # Perform the Zope-level authentication - if self._zopeAuthenticate(rq) or self._ldapAuthenticate(login,password): + # Authenticate the user. + login = rq.get('__ac_name', None) + if self.getUser(authentify=True): msg = self.translate('login_ok') logMsg = 'User "%s" logged in.' % login else: - rq.RESPONSE.expireCookie('_appy_', path='/') msg = self.translate('login_ko') logMsg = 'Authentication failed with login "%s".' % login self.log(logMsg) @@ -1051,33 +1133,19 @@ class ToolMixin(BaseMixin): # a is the object the object was accessed through # c is the physical container of the object a, c, n, v = self._getobcontext(v, request) - print c - # Try to get user name and password from basic authentication - login, password = self.identify(auth) - if not login: - # Try to get them from a cookie - login, password = gutils.readCookie(request) - if not login: - # Maybe the user just entered his credentials. The cookie could - # have been set in the response, but is not in the request. - login = request.get('__ac_name', None) - password = request.get('__ac_password', None) - # Try to authenticate this user - user = self.authenticate(login, password, request) - emergency = self._emergency_user - if emergency and (user is emergency): - # It is the emergency user. - return emergency.__of__(self) - elif user is None: + # Identify and authentify the user + user = self.getParentNode().config.getUser(authentify=True) + if not user: # Login and/or password incorrect. Try to authorize and return the # anonymous user. if self.authorize(self._nobody, a, c, n, v, roles): return self._nobody.__of__(self) else: - return # Anonymous can't acces this object + return else: # We found a user and his password was correct. Try to authorize him # against the published object. + user = user.getZopeUser() if self.authorize(user, a, c, n, v, roles): return user.__of__(self) # That didn't work. Try to authorize the anonymous user. @@ -1090,30 +1158,6 @@ class ToolMixin(BaseMixin): from AccessControl.User import BasicUserFolder BasicUserFolder.validate = validate - def getUser(self): - '''Gets the User instance (Appy wrapper) corresponding to the current - user.''' - tool = self.appy() - rq = tool.request - # Try first to return the user that can be cached on the request. - if hasattr(rq, 'user'): return rq.user - # Get the user login from the authentication cookie. - login, password = gutils.readCookie(rq) - if not login: # It is the anonymous user or the system. - # If we have a real request object, it is the anonymous user. - login = (rq.__class__.__name__ == 'Object') and 'system' or 'anon' - # Get the User object from a query in the catalog. - user = tool.search1('User', noSecurity=True, login=login) - # It is possible that we find no user here: it happens before users - # "anon" and "system" are created, at first Zope startup. - if not user: return - rq.user = user - # Precompute some values or this user for performance reasons. - rq.userRoles = user.getRoles() - rq.userLogins = user.getLogins() - rq.zopeUser = user.getZopeUser() - return user - def getUserLine(self): '''Returns a info about the currently logged user as a 2-tuple: first elem is the one-line user info as shown on every page; second line is diff --git a/gen/model.py b/gen/model.py index 4ef941b..a46a0dd 100644 --- a/gen/model.py +++ b/gen/model.py @@ -141,7 +141,8 @@ class ModelClass: # The User class --------------------------------------------------------------- class User(ModelClass): _appy_attributes = ['title', 'name', 'firstName', 'login', 'password1', - 'password2', 'email', 'roles', 'groups', 'toTool'] + 'password2', 'email', 'roles', 'source', 'groups', + 'toTool'] # All methods defined below are fake. Real versions are in the wrapper. title = gen.String(show=False, indexed=True) gm = {'group': 'main', 'width': 25} @@ -150,6 +151,9 @@ class User(ModelClass): firstName = gen.String(show=showName, **gm) def showEmail(self): pass email = gen.String(show=showEmail, **gm) + # Where is this user stored? By default, in the ZODB. But the user can be + # stored in an external LDAP (source='ldap'). + source = gen.String(show=False, default='zodb', layouts='f', **gm) gm['multiplicity'] = (1,1) def showLogin(self): pass def validateLogin(self): pass @@ -164,9 +168,6 @@ class User(ModelClass): def showRoles(self): pass roles = gen.String(show=showRoles, indexed=True, validator=gen.Selection('getGrantableRoles'), **gm) - # Where is this user stored? By default, in the ZODB. But the user can be - # stored in an external LDAP. - source = gen.String(show=False, default='zodb', layouts='f', **gm) # The Group class -------------------------------------------------------------- class Group(ModelClass): diff --git a/shared/ldap_connector.py b/shared/ldap_connector.py new file mode 100644 index 0000000..adcd218 --- /dev/null +++ b/shared/ldap_connector.py @@ -0,0 +1,72 @@ +# ------------------------------------------------------------------------------ +try: + import ldap +except ImportError: + # For people that do not care about ldap. + ldap = None + +# ------------------------------------------------------------------------------ +class LdapConnector: + '''This class manages the communication with a LDAP server.''' + def __init__(self, serverUri, tentatives=5, ssl=False, timeout=5, + tool=None): + # The URI of the LDAP server, ie ldap://some.ldap.server:389. + self.serverUri = serverUri + # The object that will represent the LDAP server + self.server = None + # The number of trials the connector will at most perform to the LDAP + # server, when executing a query in it. + self.tentatives = tentatives + self.ssl = ssl + # The timeout for every query to the LDAP. + self.timeout = timeout + # A tool from a Appy application can be given and will be used, ie for + # logging purpose. + self.tool = tool + + def log(self, message, type='info'): + '''Logs via a Appy tool if available.''' + if self.tool: self.tool.log(message, type=type) + + def connect(self, login, password): + '''Connects to the LDAP server using p_login and p_password as + credentials. If the connection succeeds, a server object is created + in self.server and tuple (True, None) is returned. Else, tuple + (False, errorMessage) is returned.''' + try: + self.server = ldap.initialize(self.serverUri) + self.server.simple_bind_s(login, password) + return True, None + except ldap.LDAPError, le: + message = str(le) + self.log('%s: connect error with login %s (%s).' % \ + (self.serverUri, login, message)) + return False, message + + def getFilter(self, values): + '''Builds and returns a LDAP filter based on p_values, a tuple or list + of tuples (name,value).''' + return '(&%s)' % ''.join(['(%s=%s)' % (n, v) for n, v in values]) + + def search(self, baseDn, scope, filter, attributes=None): + '''Performs a query in the LDAP at node p_baseDn, with the given + p_scope. p_filter is a LDAP filter that constraints the search. It + can be computed from a list of tuples (value, name) by method + m_getFilter. p_attributes is the list of attributes that we will + retrieve from the LDAP. If None, all attributes will be retrieved.''' + if self.ssl: self.server.start_tls_s() + try: + # Get the LDAP constant corresponding to p_scope. + scope = getattr(ldap, 'SCOPE_%s' % scope) + # Perform the query. + for i in range(self.tentatives): + try: + return self.server.search_st(\ + baseDn, scope, filterstr=filter, attrlist=attributes, + timeout=self.timeout) + except ldap.TIMEOUT: + pass + except ldap.LDAPError, le: + self.log('LDAP query error %s: %s' % \ + (le.__class__.__name__, str(le))) +# ------------------------------------------------------------------------------