[gen, shared] More work on LDAP.

This commit is contained in:
Gaetan Delannay 2013-09-06 16:19:56 +02:00
parent 1be7d9f0ab
commit 79d89aca2b
5 changed files with 231 additions and 103 deletions

View file

@ -368,7 +368,12 @@ class User(Model):
# ------------------------------------------------------------------------------ # ------------------------------------------------------------------------------
class LdapConfig: 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): def __init__(self):
self.server = '' # Name of the LDAP server self.server = '' # Name of the LDAP server
self.port = None # Port for this server. self.port = None # Port for this server.
@ -378,7 +383,17 @@ class LdapConfig:
self.adminPassword = '' self.adminPassword = ''
# LDAP attribute to use as login for authenticating users. # LDAP attribute to use as login for authenticating users.
self.loginAttribute = 'dn' # Can also be "mail", "sAMAccountName", "cn" 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): def getServerUri(self):
'''Returns the complete URI for accessing the LDAP, ie '''Returns the complete URI for accessing the LDAP, ie
@ -386,6 +401,37 @@ class LdapConfig:
port = self.port or 389 port = self.port or 389
return 'ldap://%s:%d' % (self.server, port) 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: class Config:
'''If you want to specify some configuration parameters for appy.gen and '''If you want to specify some configuration parameters for appy.gen and
@ -397,7 +443,6 @@ class Config:
class Config(appy.gen.Config): class Config(appy.gen.Config):
langages = ('en', 'fr') langages = ('en', 'fr')
''' '''
# For every language code that you specify in this list, appy.gen will # For every language code that you specify in this list, appy.gen will
# produce and maintain translation files. # produce and maintain translation files.
languages = ['en'] languages = ['en']

View file

@ -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
# ------------------------------------------------------------------------------

View file

@ -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 from appy import Object
import appy.gen 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.layout import ColumnLayout
from appy.gen import utils as gutils from appy.gen import utils as gutils
from appy.gen.mixins import BaseMixin 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 mimeTypes
from appy.shared import utils as sutils from appy.shared import utils as sutils
from appy.shared.data import languages from appy.shared.data import languages
from appy.shared.ldap_connector import LdapConnector
try: try:
from AccessControl.ZopeSecurityPolicy import _noroles from AccessControl.ZopeSecurityPolicy import _noroles
except ImportError: except ImportError:
@ -979,21 +980,106 @@ class ToolMixin(BaseMixin):
'''Returns the encrypted version of clear p_password.''' '''Returns the encrypted version of clear p_password.'''
return self.acl_users._encryptPassword(password) return self.acl_users._encryptPassword(password)
def _zopeAuthenticate(self, request): def getUser(self, authentify=False):
'''Performs the Zope-level authentication. Returns True if '''Gets the current user. If p_authentify is True, in addition to
authentication succeeds.''' finding the logged user and returning it (=identification), we check
user = self.acl_users.validate(request) if found credentials are valid (=authentification).'''
return user.getUserName() != 'Anonymous User' # 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): def _ldapAuthenticate(self, login, password):
'''Performs a LDAP-based authentication. Returns True if authentication '''Performs a LDAP-based authentication. Returns True if authentication
succeeds.''' succeeds.'''
# Check if LDAP is configured. # Check if LDAP is configured.
ldapConfig = self.getProductConfig(True).ldap cfg = self.getProductConfig(True).ldap
if not ldapConfig: return if not cfg: return
user = ldap.authenticate(login, password, ldapConfig, self) # Get a connector to the LDAP server and connect to the LDAP server.
if not user: return serverUri = cfg.getServerUri()
return True 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): def performLogin(self):
'''Logs the user in.''' '''Logs the user in.'''
@ -1004,16 +1090,12 @@ class ToolMixin(BaseMixin):
if jsEnabled and not cookiesEnabled: if jsEnabled and not cookiesEnabled:
msg = self.translate('enable_cookies') msg = self.translate('enable_cookies')
return self.goto(urlBack, msg) return self.goto(urlBack, msg)
# Extract the login and password, and create an authentication cookie # Authenticate the user.
login = rq.get('__ac_name', '') login = rq.get('__ac_name', None)
password = rq.get('__ac_password', '') if self.getUser(authentify=True):
gutils.writeCookie(login, password, rq)
# Perform the Zope-level authentication
if self._zopeAuthenticate(rq) or self._ldapAuthenticate(login,password):
msg = self.translate('login_ok') msg = self.translate('login_ok')
logMsg = 'User "%s" logged in.' % login logMsg = 'User "%s" logged in.' % login
else: else:
rq.RESPONSE.expireCookie('_appy_', path='/')
msg = self.translate('login_ko') msg = self.translate('login_ko')
logMsg = 'Authentication failed with login "%s".' % login logMsg = 'Authentication failed with login "%s".' % login
self.log(logMsg) self.log(logMsg)
@ -1051,33 +1133,19 @@ class ToolMixin(BaseMixin):
# a is the object the object was accessed through # a is the object the object was accessed through
# c is the physical container of the object # c is the physical container of the object
a, c, n, v = self._getobcontext(v, request) a, c, n, v = self._getobcontext(v, request)
print c # Identify and authentify the user
# Try to get user name and password from basic authentication user = self.getParentNode().config.getUser(authentify=True)
login, password = self.identify(auth) if not user:
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:
# Login and/or password incorrect. Try to authorize and return the # Login and/or password incorrect. Try to authorize and return the
# anonymous user. # anonymous user.
if self.authorize(self._nobody, a, c, n, v, roles): if self.authorize(self._nobody, a, c, n, v, roles):
return self._nobody.__of__(self) return self._nobody.__of__(self)
else: else:
return # Anonymous can't acces this object return
else: else:
# We found a user and his password was correct. Try to authorize him # We found a user and his password was correct. Try to authorize him
# against the published object. # against the published object.
user = user.getZopeUser()
if self.authorize(user, a, c, n, v, roles): if self.authorize(user, a, c, n, v, roles):
return user.__of__(self) return user.__of__(self)
# That didn't work. Try to authorize the anonymous user. # That didn't work. Try to authorize the anonymous user.
@ -1090,30 +1158,6 @@ class ToolMixin(BaseMixin):
from AccessControl.User import BasicUserFolder from AccessControl.User import BasicUserFolder
BasicUserFolder.validate = validate 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): def getUserLine(self):
'''Returns a info about the currently logged user as a 2-tuple: first '''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 elem is the one-line user info as shown on every page; second line is

View file

@ -141,7 +141,8 @@ class ModelClass:
# The User class --------------------------------------------------------------- # The User class ---------------------------------------------------------------
class User(ModelClass): class User(ModelClass):
_appy_attributes = ['title', 'name', 'firstName', 'login', 'password1', _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. # All methods defined below are fake. Real versions are in the wrapper.
title = gen.String(show=False, indexed=True) title = gen.String(show=False, indexed=True)
gm = {'group': 'main', 'width': 25} gm = {'group': 'main', 'width': 25}
@ -150,6 +151,9 @@ class User(ModelClass):
firstName = gen.String(show=showName, **gm) firstName = gen.String(show=showName, **gm)
def showEmail(self): pass def showEmail(self): pass
email = gen.String(show=showEmail, **gm) 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) gm['multiplicity'] = (1,1)
def showLogin(self): pass def showLogin(self): pass
def validateLogin(self): pass def validateLogin(self): pass
@ -164,9 +168,6 @@ class User(ModelClass):
def showRoles(self): pass def showRoles(self): pass
roles = gen.String(show=showRoles, indexed=True, roles = gen.String(show=showRoles, indexed=True,
validator=gen.Selection('getGrantableRoles'), **gm) 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 -------------------------------------------------------------- # The Group class --------------------------------------------------------------
class Group(ModelClass): class Group(ModelClass):

72
shared/ldap_connector.py Normal file
View file

@ -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)))
# ------------------------------------------------------------------------------