## $Id: authentication.py 13182 2015-07-22 04:51:54Z henrik $ ## ## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann ## This program is free software; you can redistribute it and/or modify ## it under the terms of the GNU General Public License as published by ## the Free Software Foundation; either version 2 of the License, or ## (at your option) any later version. ## ## This program is distributed in the hope that it will be useful, ## but WITHOUT ANY WARRANTY; without even the implied warranty of ## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the ## GNU General Public License for more details. ## ## You should have received a copy of the GNU General Public License ## along with this program; if not, write to the Free Software ## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA ## """Authentication for Kofa. """ import grok import time from zope.event import notify from zope.component import getUtility, getUtilitiesFor from zope.component.interfaces import IFactory from zope.interface import Interface, implementedBy from zope.schema import getFields from zope.securitypolicy.interfaces import ( IPrincipalRoleMap, IPrincipalRoleManager) from zope.pluggableauth.factories import Principal from zope.pluggableauth.plugins.session import SessionCredentialsPlugin from zope.pluggableauth.interfaces import ( ICredentialsPlugin, IAuthenticatorPlugin, IAuthenticatedPrincipalFactory, AuthenticatedPrincipalCreated) from zope.publisher.interfaces import IRequest from zope.password.interfaces import IPasswordManager from zope.securitypolicy.principalrole import principalRoleManager from waeup.kofa.interfaces import (ILocalRoleSetEvent, IUserAccount, IAuthPluginUtility, IPasswordValidator, IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable, IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo) from waeup.kofa.utils.batching import BatchProcessor from waeup.kofa.permissions import get_all_roles def setup_authentication(pau): """Set up plugguble authentication utility. Sets up an IAuthenticatorPlugin and ICredentialsPlugin (for the authentication mechanism) Then looks for any external utilities that want to modify the PAU. """ pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials') pau.authenticatorPlugins = ('users',) # Give any third-party code and subpackages a chance to modify the PAU auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility) for name, util in auth_plugin_utilities: util.register(pau) def get_principal_role_manager(): """Get a role manager for principals. If we are currently 'in a site', return the role manager for the portal or the global rolemanager else. """ portal = grok.getSite() if portal is not None: return IPrincipalRoleManager(portal) return principalRoleManager class KofaSessionCredentialsPlugin(grok.GlobalUtility, SessionCredentialsPlugin): grok.provides(ICredentialsPlugin) grok.name('credentials') loginpagename = 'login' loginfield = 'form.login' passwordfield = 'form.password' class KofaPrincipalInfo(object): """An implementation of IKofaPrincipalInfo. A Kofa principal info is created with id, login, title, description, phone, email, public_name and user_type. """ grok.implements(IKofaPrincipalInfo) def __init__(self, id, title, description, email, phone, public_name, user_type): self.id = id self.title = title self.description = description self.email = email self.phone = phone self.public_name = public_name self.user_type = user_type self.credentialsPlugin = None self.authenticatorPlugin = None def __eq__(self, obj): default = object() result = [] for name in ('id', 'title', 'description', 'email', 'phone', 'public_name', 'user_type', 'credentialsPlugin', 'authenticatorPlugin'): result.append( getattr(self, name) == getattr(obj, name, default)) return False not in result class KofaPrincipal(Principal): """A portal principal. Kofa principals provide an extra `email`, `phone`, `public_name` and `user_type` attribute extending ordinary principals. """ grok.implements(IKofaPrincipal) def __init__(self, id, title=u'', description=u'', email=u'', phone=None, public_name=u'', user_type=u'', prefix=None): self.id = id if prefix is not None: self.id = '%s.%s' % (prefix, self.id) self.title = title self.description = description self.groups = [] self.email = email self.phone = phone self.public_name = public_name self.user_type = user_type def __repr__(self): return 'KofaPrincipal(%r)' % self.id class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter): """Creates 'authenticated' Kofa principals. Adapts (principal info, request) to a KofaPrincipal instance. This adapter is used by the standard PAU to transform KofaPrincipalInfos into KofaPrincipal instances. """ grok.adapts(IKofaPrincipalInfo, IRequest) grok.implements(IAuthenticatedPrincipalFactory) def __init__(self, info, request): self.info = info self.request = request def __call__(self, authentication): principal = KofaPrincipal( self.info.id, self.info.title, self.info.description, self.info.email, self.info.phone, self.info.public_name, self.info.user_type, authentication.prefix, ) notify( AuthenticatedPrincipalCreated( authentication, principal, self.info, self.request)) return principal class FailedLoginInfo(grok.Model): grok.implements(IFailedLoginInfo) def __init__(self, num=0, last=None): self.num = num self.last = last return def as_tuple(self): return (self.num, self.last) def set_values(self, num=0, last=None): self.num, self.last = num, last self._p_changed = True pass def increase(self): self.set_values(num=self.num + 1, last=time.time()) pass def reset(self): self.set_values(num=0, last=None) pass class Account(grok.Model): """Kofa user accounts store infos about a user. Beside the usual data and an (encrypted) password, accounts also have a persistent attribute `failed_logins` which is an instance of `waeup.kofa.authentication.FailedLoginInfo`. This attribute can be manipulated directly (set new value, increase values, or reset). """ grok.implements(IUserAccount) def __init__(self, name, password, title=None, description=None, email=None, phone=None, public_name=None, roles = []): self.name = name if title is None: title = name self.title = title self.description = description self.email = email self.phone = phone self.public_name = public_name self.suspended = False self.setPassword(password) self.setSiteRolesForPrincipal(roles) # We don't want to share this dict with other accounts self._local_roles = dict() self.failed_logins = FailedLoginInfo() def setPassword(self, password): passwordmanager = getUtility(IPasswordManager, 'SSHA') self.password = passwordmanager.encodePassword(password) def checkPassword(self, password): if not isinstance(password, basestring): return False if not self.password: # unset/empty passwords do never match return False if self.suspended == True: return False passwordmanager = getUtility(IPasswordManager, 'SSHA') return passwordmanager.checkPassword(self.password, password) def getSiteRolesForPrincipal(self): prm = get_principal_role_manager() roles = [x[0] for x in prm.getRolesForPrincipal(self.name) if x[0].startswith('waeup.')] return roles def setSiteRolesForPrincipal(self, roles): prm = get_principal_role_manager() old_roles = self.getSiteRolesForPrincipal() if sorted(old_roles) == sorted(roles): return for role in old_roles: # Remove old roles, not to be set now... if role.startswith('waeup.') and role not in roles: prm.unsetRoleForPrincipal(role, self.name) for role in roles: # Convert role to ASCII string to be in line with the # event handler prm.assignRoleToPrincipal(str(role), self.name) return roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal) def getLocalRoles(self): return self._local_roles def notifyLocalRoleChanged(self, obj, role_id, granted=True): objects = self._local_roles.get(role_id, []) if granted and obj not in objects: objects.append(obj) if not granted and obj in objects: objects.remove(obj) self._local_roles[role_id] = objects if len(objects) == 0: del self._local_roles[role_id] self._p_changed = True return class UserAuthenticatorPlugin(grok.GlobalUtility): grok.implements(IAuthenticatorPlugin) grok.provides(IAuthenticatorPlugin) grok.name('users') def authenticateCredentials(self, credentials): if not isinstance(credentials, dict): return None if not ('login' in credentials and 'password' in credentials): return None account = self.getAccount(credentials['login']) if account is None: return None # The following shows how 'time penalties' could be enforced # on failed logins. First three failed logins are 'for # free'. After that the user has to wait for 1, 2, 4, 8, 16, # 32, ... seconds before a login can succeed. # There are, however, some problems to discuss, before we # really use this in all authenticators. #num, last = account.failed_logins.as_tuple() #if (num > 2) and (time.time() < (last + 2**(num-3))): # # tried login while account still blocked due to previous # # login errors. # return None if not account.checkPassword(credentials['password']): #account.failed_logins.increase() return None return KofaPrincipalInfo( id=account.name, title=account.title, description=account.description, email=account.email, phone=account.phone, public_name=account.public_name, user_type=u'user') def principalInfo(self, id): account = self.getAccount(id) if account is None: return None return KofaPrincipalInfo( id=account.name, title=account.title, description=account.description, email=account.email, phone=account.phone, public_name=account.public_name, user_type=u'user') def getAccount(self, login): # ... look up the account object and return it ... userscontainer = self.getUsersContainer() if userscontainer is None: return return userscontainer.get(login, None) def addAccount(self, account): userscontainer = self.getUsersContainer() if userscontainer is None: return # XXX: complain if name already exists... userscontainer.addAccount(account) def addUser(self, name, password, title=None, description=None): userscontainer = self.getUsersContainer() if userscontainer is None: return userscontainer.addUser(name, password, title, description) def getUsersContainer(self): site = grok.getSite() return site['users'] class PasswordValidator(grok.GlobalUtility): grok.implements(IPasswordValidator) def validate_password(self, pw, pw_repeat): errors = [] if len(pw) < 3: errors.append('Password must have at least 3 chars.') if pw != pw_repeat: errors.append('Passwords do not match.') return errors class LocalRoleSetEvent(object): grok.implements(ILocalRoleSetEvent) def __init__(self, object, role_id, principal_id, granted=True): self.object = object self.role_id = role_id self.principal_id = principal_id self.granted = granted @grok.subscribe(IUserAccount, grok.IObjectRemovedEvent) def handle_account_removed(account, event): """When an account is removed, local and global roles might have to be deleted. """ local_roles = account.getLocalRoles() principal = account.name for role_id, object_list in local_roles.items(): for object in object_list: try: role_manager = IPrincipalRoleManager(object) except TypeError: # No Account object, no role manager, no local roles to remove continue role_manager.unsetRoleForPrincipal(role_id, principal) role_manager = IPrincipalRoleManager(grok.getSite()) roles = account.getSiteRolesForPrincipal() for role_id in roles: role_manager.unsetRoleForPrincipal(role_id, principal) return @grok.subscribe(IUserAccount, grok.IObjectAddedEvent) def handle_account_added(account, event): """When an account is added, the local owner role and the global AcademicsOfficer role must be set. """ # We set the local Owner role role_manager_account = IPrincipalRoleManager(account) role_manager_account.assignRoleToPrincipal( 'waeup.local.Owner', account.name) # We set the global AcademicsOfficer role site = grok.getSite() role_manager_site = IPrincipalRoleManager(site) role_manager_site.assignRoleToPrincipal( 'waeup.AcademicsOfficer', account.name) # Finally we have to notify the user account that the local role # of the same object has changed notify(LocalRoleSetEvent( account, 'waeup.local.Owner', account.name, granted=True)) return @grok.subscribe(Interface, ILocalRoleSetEvent) def handle_local_role_changed(obj, event): site = grok.getSite() if site is None: return users = site.get('users', None) if users is None: return if event.principal_id not in users.keys(): return user = users[event.principal_id] user.notifyLocalRoleChanged(event.object, event.role_id, event.granted) return @grok.subscribe(Interface, grok.IObjectRemovedEvent) def handle_local_roles_on_obj_removed(obj, event): try: role_map = IPrincipalRoleMap(obj) except TypeError: # no map, no roles to remove return for local_role, user_name, setting in role_map.getPrincipalsAndRoles(): notify(LocalRoleSetEvent( obj, local_role, user_name, granted=False)) return class UserAccountFactory(grok.GlobalUtility): """A factory for user accounts. This factory is only needed for imports. """ grok.implements(IFactory) grok.name(u'waeup.UserAccount') title = u"Create a user.", description = u"This factory instantiates new user account instances." def __call__(self, *args, **kw): return Account(name=None, password='') def getInterfaces(self): return implementedBy(Account) class UserProcessor(BatchProcessor): """The User Processor processes user accounts, i.e. `Account` objects in the ``users`` container. The `roles` columns must contain Python list expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``. The processor does not import local roles. These can be imported by means of batch processors in the academic section. """ grok.implements(IBatchProcessor) grok.provides(IBatchProcessor) grok.context(Interface) util_name = 'userprocessor' grok.name(util_name) name = u'User Processor' iface = IUserAccount location_fields = ['name',] factory_name = 'waeup.UserAccount' mode = None def parentsExist(self, row, site): return 'users' in site.keys() def entryExists(self, row, site): return row['name'] in site['users'].keys() def getParent(self, row, site): return site['users'] def getEntry(self, row, site): if not self.entryExists(row, site): return None parent = self.getParent(row, site) return parent.get(row['name']) def addEntry(self, obj, row, site): parent = self.getParent(row, site) parent.addAccount(obj) return def delEntry(self, row, site): user = self.getEntry(row, site) if user is not None: parent = self.getParent(row, site) grok.getSite().logger.info('%s - %s - User removed' % (self.name, row['name'])) del parent[user.name] pass def updateEntry(self, obj, row, site, filename): """Update obj to the values given in row. """ changed = [] for key, value in row.items(): if key == 'roles': # We cannot simply set the roles attribute here because # we can't assure that the name attribute is set before # the roles attribute is set. continue # Skip fields to be ignored. if value == IGNORE_MARKER: continue if not hasattr(obj, key): continue setattr(obj, key, value) changed.append('%s=%s' % (key, value)) roles = row.get('roles', IGNORE_MARKER) if roles not in ('', IGNORE_MARKER): evalvalue = eval(roles) if isinstance(evalvalue, list): setattr(obj, 'roles', evalvalue) changed.append('roles=%s' % roles) # Log actions... items_changed = ', '.join(changed) grok.getSite().logger.info('%s - %s - %s - updated: %s' % (self.name, filename, row['name'], items_changed)) return def checkConversion(self, row, mode='ignore'): """Validates all values in row. """ errs, inv_errs, conv_dict = super( UserProcessor, self).checkConversion(row, mode=mode) # We need to check if roles exist. roles = row.get('roles', IGNORE_MARKER) all_roles = [i[0] for i in get_all_roles()] if roles not in ('', IGNORE_MARKER): evalvalue = eval(roles) for role in evalvalue: if role not in all_roles: errs.append(('roles','invalid role')) return errs, inv_errs, conv_dict class UsersPlugin(grok.GlobalUtility): """A plugin that updates users. """ grok.implements(IKofaPluggable) grok.name('users') deprecated_attributes = [] def setup(self, site, name, logger): return def update(self, site, name, logger): users = site['users'] items = getFields(IUserAccount).items() for user in users.values(): # Add new attributes for i in items: if not hasattr(user,i[0]): setattr(user,i[0],i[1].missing_value) logger.info( 'UsersPlugin: %s attribute %s added.' % ( user.name,i[0])) if not hasattr(user, 'failed_logins'): # add attribute `failed_logins`... user.failed_logins = FailedLoginInfo() logger.info( 'UsersPlugin: attribute failed_logins added.') # Remove deprecated attributes for i in self.deprecated_attributes: try: delattr(user,i) logger.info( 'UsersPlugin: %s attribute %s deleted.' % ( user.name,i)) except AttributeError: pass return