source: main/waeup.kofa/trunk/src/waeup/kofa/authentication.py @ 12910

Last change on this file since 12910 was 12869, checked in by Henrik Bettermann, 10 years ago

Start documenting batch processors.

  • Property svn:keywords set to Id
File size: 19.6 KB
RevLine 
[7193]1## $Id: authentication.py 12869 2015-04-22 19:14:14Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
[7819]18"""Authentication for Kofa.
[4073]19"""
20import grok
[10055]21import time
[7169]22from zope.event import notify
[5900]23from zope.component import getUtility, getUtilitiesFor
[8973]24from zope.component.interfaces import IFactory
[10055]25from zope.interface import Interface, implementedBy
[8756]26from zope.schema import getFields
[7169]27from zope.securitypolicy.interfaces import (
28    IPrincipalRoleMap, IPrincipalRoleManager)
[7233]29from zope.pluggableauth.factories import Principal
[6610]30from zope.pluggableauth.plugins.session import SessionCredentialsPlugin
31from zope.pluggableauth.interfaces import (
[7233]32        ICredentialsPlugin, IAuthenticatorPlugin,
33        IAuthenticatedPrincipalFactory, AuthenticatedPrincipalCreated)
34from zope.publisher.interfaces import IRequest
[6610]35from zope.password.interfaces import IPasswordManager
[4129]36from zope.securitypolicy.principalrole import principalRoleManager
[7811]37from waeup.kofa.interfaces import (ILocalRoleSetEvent,
[7233]38    IUserAccount, IAuthPluginUtility, IPasswordValidator,
[8973]39    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable,
[10055]40    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
[8973]41from waeup.kofa.utils.batching import BatchProcessor
[12190]42from waeup.kofa.permissions import get_all_roles
[4073]43
44def setup_authentication(pau):
45    """Set up plugguble authentication utility.
46
47    Sets up an IAuthenticatorPlugin and
48    ICredentialsPlugin (for the authentication mechanism)
[5900]49
50    Then looks for any external utilities that want to modify the PAU.
[4073]51    """
[6661]52    pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials')
[6672]53    pau.authenticatorPlugins = ('users',)
[4073]54
[5900]55    # Give any third-party code and subpackages a chance to modify the PAU
56    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
[5901]57    for name, util in auth_plugin_utilities:
[5900]58        util.register(pau)
59
[6673]60def get_principal_role_manager():
61    """Get a role manager for principals.
62
63    If we are currently 'in a site', return the role manager for the
64    portal or the global rolemanager else.
65    """
66    portal = grok.getSite()
67    if portal is not None:
68        return IPrincipalRoleManager(portal)
69    return principalRoleManager
70
[7819]71class KofaSessionCredentialsPlugin(grok.GlobalUtility,
[4073]72                                    SessionCredentialsPlugin):
73    grok.provides(ICredentialsPlugin)
74    grok.name('credentials')
75
76    loginpagename = 'login'
77    loginfield = 'form.login'
78    passwordfield = 'form.password'
79
[7819]80class KofaPrincipalInfo(object):
81    """An implementation of IKofaPrincipalInfo.
[4073]82
[7819]83    A Kofa principal info is created with id, login, title, description,
[8757]84    phone, email, public_name and user_type.
[7233]85    """
[7819]86    grok.implements(IKofaPrincipalInfo)
[7233]87
[10055]88    def __init__(self, id, title, description, email, phone, public_name,
89                 user_type):
[4073]90        self.id = id
91        self.title = title
92        self.description = description
[7233]93        self.email = email
94        self.phone = phone
[8757]95        self.public_name = public_name
[7240]96        self.user_type = user_type
[4073]97        self.credentialsPlugin = None
98        self.authenticatorPlugin = None
99
[10055]100    def __eq__(self, obj):
101        default = object()
102        result = []
103        for name in ('id', 'title', 'description', 'email', 'phone',
104                     'public_name', 'user_type', 'credentialsPlugin',
105                     'authenticatorPlugin'):
106            result.append(
107                getattr(self, name) == getattr(obj, name, default))
108        return False not in result
109
[7819]110class KofaPrincipal(Principal):
[7233]111    """A portal principal.
112
[10055]113    Kofa principals provide an extra `email`, `phone`, `public_name`
114    and `user_type` attribute extending ordinary principals.
[7233]115    """
116
[7819]117    grok.implements(IKofaPrincipal)
[7233]118
119    def __init__(self, id, title=u'', description=u'', email=u'',
[8757]120                 phone=None, public_name=u'', user_type=u'', prefix=None):
[7233]121        self.id = id
[7239]122        if prefix is not None:
123            self.id = '%s.%s' % (prefix, self.id)
[7233]124        self.title = title
125        self.description = description
126        self.groups = []
127        self.email = email
128        self.phone = phone
[8757]129        self.public_name = public_name
[7240]130        self.user_type = user_type
[7233]131
132    def __repr__(self):
[7819]133        return 'KofaPrincipal(%r)' % self.id
[7233]134
[7819]135class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
136    """Creates 'authenticated' Kofa principals.
[7233]137
[7819]138    Adapts (principal info, request) to a KofaPrincipal instance.
[7233]139
140    This adapter is used by the standard PAU to transform
[7819]141    KofaPrincipalInfos into KofaPrincipal instances.
[7233]142    """
[7819]143    grok.adapts(IKofaPrincipalInfo, IRequest)
[7233]144    grok.implements(IAuthenticatedPrincipalFactory)
145
146    def __init__(self, info, request):
147        self.info = info
148        self.request = request
149
150    def __call__(self, authentication):
[7819]151        principal = KofaPrincipal(
[7233]152            self.info.id,
153            self.info.title,
154            self.info.description,
155            self.info.email,
156            self.info.phone,
[8757]157            self.info.public_name,
[7240]158            self.info.user_type,
[7239]159            authentication.prefix,
[7233]160            )
161        notify(
162            AuthenticatedPrincipalCreated(
163                authentication, principal, self.info, self.request))
164        return principal
165
[10055]166class FailedLoginInfo(grok.Model):
167    grok.implements(IFailedLoginInfo)
168
169    def __init__(self, num=0, last=None):
170        self.num = num
171        self.last = last
172        return
173
174    def as_tuple(self):
175        return (self.num, self.last)
176
177    def set_values(self, num=0, last=None):
178        self.num, self.last = num, last
179        self._p_changed = True
180        pass
181
182    def increase(self):
183        self.set_values(num=self.num + 1, last=time.time())
184        pass
185
186    def reset(self):
187        self.set_values(num=0, last=None)
188        pass
189
[4073]190class Account(grok.Model):
[10055]191    """Kofa user accounts store infos about a user.
192
193    Beside the usual data and an (encrypted) password, accounts also
194    have a persistent attribute `failed_logins` which is an instance
195    of `waeup.kofa.authentication.FailedLoginInfo`.
196
197    This attribute can be manipulated directly (set new value,
198    increase values, or reset).
199    """
[4109]200    grok.implements(IUserAccount)
[4129]201
[4125]202    def __init__(self, name, password, title=None, description=None,
[8756]203                 email=None, phone=None, public_name=None, roles = []):
[4073]204        self.name = name
[4087]205        if title is None:
206            title = name
207        self.title = title
208        self.description = description
[7221]209        self.email = email
[7233]210        self.phone = phone
[8756]211        self.public_name = public_name
[4073]212        self.setPassword(password)
[7636]213        self.setSiteRolesForPrincipal(roles)
[10055]214
[6180]215        # We don't want to share this dict with other accounts
216        self._local_roles = dict()
[10055]217        self.failed_logins = FailedLoginInfo()
[4073]218
219    def setPassword(self, password):
[8343]220        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]221        self.password = passwordmanager.encodePassword(password)
222
223    def checkPassword(self, password):
[8344]224        if not isinstance(password, basestring):
225            return False
226        if not self.password:
227            # unset/empty passwords do never match
228            return False
[8343]229        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]230        return passwordmanager.checkPassword(self.password, password)
231
[7177]232    def getSiteRolesForPrincipal(self):
[6673]233        prm = get_principal_role_manager()
[4129]234        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
235                 if x[0].startswith('waeup.')]
236        return roles
[5055]237
[7177]238    def setSiteRolesForPrincipal(self, roles):
[6673]239        prm = get_principal_role_manager()
[7177]240        old_roles = self.getSiteRolesForPrincipal()
[7658]241        if sorted(old_roles) == sorted(roles):
242            return
[4129]243        for role in old_roles:
244            # Remove old roles, not to be set now...
245            if role.startswith('waeup.') and role not in roles:
246                prm.unsetRoleForPrincipal(role, self.name)
247        for role in roles:
[7658]248            # Convert role to ASCII string to be in line with the
249            # event handler
250            prm.assignRoleToPrincipal(str(role), self.name)
251        return
[4129]252
[7177]253    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
[4129]254
[6180]255    def getLocalRoles(self):
256        return self._local_roles
257
258    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
259        objects = self._local_roles.get(role_id, [])
260        if granted and obj not in objects:
261            objects.append(obj)
262        if not granted and obj in objects:
263            objects.remove(obj)
264        self._local_roles[role_id] = objects
265        if len(objects) == 0:
266            del self._local_roles[role_id]
[6182]267        self._p_changed = True
[6180]268        return
269
[4073]270class UserAuthenticatorPlugin(grok.GlobalUtility):
[6625]271    grok.implements(IAuthenticatorPlugin)
[4073]272    grok.provides(IAuthenticatorPlugin)
273    grok.name('users')
274
275    def authenticateCredentials(self, credentials):
276        if not isinstance(credentials, dict):
277            return None
278        if not ('login' in credentials and 'password' in credentials):
279            return None
280        account = self.getAccount(credentials['login'])
281        if account is None:
282            return None
[10055]283        # The following shows how 'time penalties' could be enforced
284        # on failed logins. First three failed logins are 'for
285        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
286        # 32, ... seconds before a login can succeed.
287        # There are, however, some problems to discuss, before we
288        # really use this in all authenticators.
289
290        #num, last = account.failed_logins.as_tuple()
291        #if (num > 2) and (time.time() < (last + 2**(num-3))):
292        #    # tried login while account still blocked due to previous
293        #    # login errors.
294        #    return None
[4073]295        if not account.checkPassword(credentials['password']):
[10055]296            #account.failed_logins.increase()
[4073]297            return None
[7819]298        return KofaPrincipalInfo(
[7315]299            id=account.name,
300            title=account.title,
301            description=account.description,
302            email=account.email,
303            phone=account.phone,
[8757]304            public_name=account.public_name,
[7315]305            user_type=u'user')
[4073]306
307    def principalInfo(self, id):
308        account = self.getAccount(id)
309        if account is None:
310            return None
[7819]311        return KofaPrincipalInfo(
[7315]312            id=account.name,
313            title=account.title,
314            description=account.description,
315            email=account.email,
316            phone=account.phone,
[8757]317            public_name=account.public_name,
[7315]318            user_type=u'user')
[4073]319
320    def getAccount(self, login):
[4087]321        # ... look up the account object and return it ...
[7172]322        userscontainer = self.getUsersContainer()
323        if userscontainer is None:
[4087]324            return
[7172]325        return userscontainer.get(login, None)
[4087]326
327    def addAccount(self, account):
[7172]328        userscontainer = self.getUsersContainer()
329        if userscontainer is None:
[4087]330            return
331        # XXX: complain if name already exists...
[7172]332        userscontainer.addAccount(account)
[4087]333
[4091]334    def addUser(self, name, password, title=None, description=None):
[7172]335        userscontainer = self.getUsersContainer()
336        if userscontainer is None:
[4091]337            return
[7172]338        userscontainer.addUser(name, password, title, description)
[5055]339
[7172]340    def getUsersContainer(self):
[4087]341        site = grok.getSite()
[4743]342        return site['users']
[6203]343
[7147]344class PasswordValidator(grok.GlobalUtility):
345
346  grok.implements(IPasswordValidator)
347
348  def validate_password(self, pw, pw_repeat):
349       errors = []
350       if len(pw) < 3:
351         errors.append('Password must have at least 3 chars.')
352       if pw != pw_repeat:
353         errors.append('Passwords do not match.')
354       return errors
355
[7169]356class LocalRoleSetEvent(object):
357
358    grok.implements(ILocalRoleSetEvent)
359
360    def __init__(self, object, role_id, principal_id, granted=True):
361        self.object = object
362        self.role_id = role_id
363        self.principal_id = principal_id
364        self.granted = granted
365
[6203]366@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
[6839]367def handle_account_removed(account, event):
[9313]368    """When an account is removed, local and global roles might
369    have to be deleted.
[6203]370    """
371    local_roles = account.getLocalRoles()
372    principal = account.name
[9313]373
[6203]374    for role_id, object_list in local_roles.items():
375        for object in object_list:
376            try:
377                role_manager = IPrincipalRoleManager(object)
378            except TypeError:
[9313]379                # No Account object, no role manager, no local roles to remove
[6203]380                continue
381            role_manager.unsetRoleForPrincipal(role_id, principal)
[9313]382    role_manager = IPrincipalRoleManager(grok.getSite())
383    roles = account.getSiteRolesForPrincipal()
384    for role_id in roles:
385        role_manager.unsetRoleForPrincipal(role_id, principal)
[6203]386    return
[7169]387
388@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
389def handle_account_added(account, event):
390    """When an account is added, the local owner role and the global
[7185]391    AcademicsOfficer role must be set.
[7169]392    """
[7173]393    # We set the local Owner role
394    role_manager_account = IPrincipalRoleManager(account)
395    role_manager_account.assignRoleToPrincipal(
[7169]396        'waeup.local.Owner', account.name)
[7185]397    # We set the global AcademicsOfficer role
[7173]398    site = grok.getSite()
399    role_manager_site = IPrincipalRoleManager(site)
400    role_manager_site.assignRoleToPrincipal(
[7185]401        'waeup.AcademicsOfficer', account.name)
[7173]402    # Finally we have to notify the user account that the local role
[7169]403    # of the same object has changed
404    notify(LocalRoleSetEvent(
405        account, 'waeup.local.Owner', account.name, granted=True))
406    return
407
408@grok.subscribe(Interface, ILocalRoleSetEvent)
409def handle_local_role_changed(obj, event):
410    site = grok.getSite()
411    if site is None:
412        return
413    users = site.get('users', None)
414    if users is None:
415        return
416    if event.principal_id not in users.keys():
417        return
418    user = users[event.principal_id]
419    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
420    return
421
422@grok.subscribe(Interface, grok.IObjectRemovedEvent)
423def handle_local_roles_on_obj_removed(obj, event):
424    try:
425        role_map = IPrincipalRoleMap(obj)
426    except TypeError:
427        # no map, no roles to remove
428        return
429    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
430        notify(LocalRoleSetEvent(
431                obj, local_role, user_name, granted=False))
[7315]432    return
[8756]433
[8973]434class UserAccountFactory(grok.GlobalUtility):
435    """A factory for user accounts.
436
437    This factory is only needed for imports.
438    """
439    grok.implements(IFactory)
440    grok.name(u'waeup.UserAccount')
441    title = u"Create a user.",
442    description = u"This factory instantiates new user account instances."
443
444    def __call__(self, *args, **kw):
445        return Account(name=None, password='')
446
447    def getInterfaces(self):
448        return implementedBy(Account)
449
450class UserProcessor(BatchProcessor):
[12869]451    """The User Processor processes user accounts, i.e. `Account` objects in the
452    ``users`` container.
453
454    The `roles` columns must contain Python list
455    expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``.
456
457    The processor does not import local roles. These can be imported
458    by means of batch processors in the academic section.
[8973]459    """
460    grok.implements(IBatchProcessor)
461    grok.provides(IBatchProcessor)
462    grok.context(Interface)
463    util_name = 'userprocessor'
464    grok.name(util_name)
465
466    name = u'User Processor'
467    iface = IUserAccount
468
469    location_fields = ['name',]
470    factory_name = 'waeup.UserAccount'
471
472    mode = None
473
474    def parentsExist(self, row, site):
475        return 'users' in site.keys()
476
477    def entryExists(self, row, site):
478        return row['name'] in site['users'].keys()
479
480    def getParent(self, row, site):
481        return site['users']
482
483    def getEntry(self, row, site):
484        if not self.entryExists(row, site):
485            return None
486        parent = self.getParent(row, site)
487        return parent.get(row['name'])
488
489    def addEntry(self, obj, row, site):
490        parent = self.getParent(row, site)
491        parent.addAccount(obj)
492        return
493
[9706]494    def updateEntry(self, obj, row, site, filename):
[9312]495        """Update obj to the values given in row.
496        """
497        changed = []
498        for key, value in row.items():
499            if  key == 'roles':
500                # We cannot simply set the roles attribute here because
501                # we can't assure that the name attribute is set before
502                # the roles attribute is set.
503                continue
504            # Skip fields to be ignored.
505            if value == IGNORE_MARKER:
506                continue
507            if not hasattr(obj, key):
508                continue
509            setattr(obj, key, value)
510            changed.append('%s=%s' % (key, value))
511        roles = row.get('roles', IGNORE_MARKER)
512        if roles not in ('', IGNORE_MARKER):
513            evalvalue = eval(roles)
514            if isinstance(evalvalue, list):
515                setattr(obj, 'roles', evalvalue)
516                changed.append('roles=%s' % roles)
517        # Log actions...
518        items_changed = ', '.join(changed)
[9706]519        grok.getSite().logger.info('%s - %s - %s - updated: %s'
520            % (self.name, filename, row['name'], items_changed))
[9312]521        return
[8973]522
[12190]523    def checkConversion(self, row, mode='ignore'):
524        """Validates all values in row.
525        """
526        errs, inv_errs, conv_dict = super(
527            UserProcessor, self).checkConversion(row, mode=mode)
528        # We need to check if roles exist.
529        roles = row.get('roles', None)
530        all_roles = [i[0] for i in get_all_roles()]
531        if roles not in ('', IGNORE_MARKER):
532            evalvalue = eval(roles)
533            for role in evalvalue:
534                if role not in all_roles:
535                    errs.append(('roles','invalid role'))
536        return errs, inv_errs, conv_dict
537
[8756]538class UsersPlugin(grok.GlobalUtility):
539    """A plugin that updates users.
540    """
541    grok.implements(IKofaPluggable)
542    grok.name('users')
543
544    deprecated_attributes = []
545
546    def setup(self, site, name, logger):
547        return
548
549    def update(self, site, name, logger):
550        users = site['users']
551        items = getFields(IUserAccount).items()
552        for user in users.values():
553            # Add new attributes
554            for i in items:
555                if not hasattr(user,i[0]):
556                    setattr(user,i[0],i[1].missing_value)
557                    logger.info(
558                        'UsersPlugin: %s attribute %s added.' % (
559                        user.name,i[0]))
[10055]560            if not hasattr(user, 'failed_logins'):
561                # add attribute `failed_logins`...
562                user.failed_logins = FailedLoginInfo()
563                logger.info(
564                    'UsersPlugin: attribute failed_logins added.')
[8756]565            # Remove deprecated attributes
566            for i in self.deprecated_attributes:
567                try:
568                    delattr(user,i)
569                    logger.info(
570                        'UsersPlugin: %s attribute %s deleted.' % (
571                        user.name,i))
572                except AttributeError:
573                    pass
[10055]574        return
Note: See TracBrowser for help on using the repository browser.