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

Last change on this file since 13159 was 12926, checked in by Henrik Bettermann, 9 years ago

Enable temporary suspension of officer accounts. Plugins must be updated after restart.

  • Property svn:keywords set to Id
File size: 19.6 KB
RevLine 
[7193]1## $Id: authentication.py 12926 2015-05-12 15:19:10Z 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
[12926]212        self.suspended = False
[4073]213        self.setPassword(password)
[7636]214        self.setSiteRolesForPrincipal(roles)
[10055]215
[6180]216        # We don't want to share this dict with other accounts
217        self._local_roles = dict()
[10055]218        self.failed_logins = FailedLoginInfo()
[4073]219
220    def setPassword(self, password):
[8343]221        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]222        self.password = passwordmanager.encodePassword(password)
223
224    def checkPassword(self, password):
[8344]225        if not isinstance(password, basestring):
226            return False
227        if not self.password:
228            # unset/empty passwords do never match
229            return False
[12926]230        if self.suspended == True:
231            return False
[8343]232        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]233        return passwordmanager.checkPassword(self.password, password)
234
[7177]235    def getSiteRolesForPrincipal(self):
[6673]236        prm = get_principal_role_manager()
[4129]237        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
238                 if x[0].startswith('waeup.')]
239        return roles
[5055]240
[7177]241    def setSiteRolesForPrincipal(self, roles):
[6673]242        prm = get_principal_role_manager()
[7177]243        old_roles = self.getSiteRolesForPrincipal()
[7658]244        if sorted(old_roles) == sorted(roles):
245            return
[4129]246        for role in old_roles:
247            # Remove old roles, not to be set now...
248            if role.startswith('waeup.') and role not in roles:
249                prm.unsetRoleForPrincipal(role, self.name)
250        for role in roles:
[7658]251            # Convert role to ASCII string to be in line with the
252            # event handler
253            prm.assignRoleToPrincipal(str(role), self.name)
254        return
[4129]255
[7177]256    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
[4129]257
[6180]258    def getLocalRoles(self):
259        return self._local_roles
260
261    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
262        objects = self._local_roles.get(role_id, [])
263        if granted and obj not in objects:
264            objects.append(obj)
265        if not granted and obj in objects:
266            objects.remove(obj)
267        self._local_roles[role_id] = objects
268        if len(objects) == 0:
269            del self._local_roles[role_id]
[6182]270        self._p_changed = True
[6180]271        return
272
[4073]273class UserAuthenticatorPlugin(grok.GlobalUtility):
[6625]274    grok.implements(IAuthenticatorPlugin)
[4073]275    grok.provides(IAuthenticatorPlugin)
276    grok.name('users')
277
278    def authenticateCredentials(self, credentials):
279        if not isinstance(credentials, dict):
280            return None
281        if not ('login' in credentials and 'password' in credentials):
282            return None
283        account = self.getAccount(credentials['login'])
284        if account is None:
285            return None
[10055]286        # The following shows how 'time penalties' could be enforced
287        # on failed logins. First three failed logins are 'for
288        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
289        # 32, ... seconds before a login can succeed.
290        # There are, however, some problems to discuss, before we
291        # really use this in all authenticators.
292
293        #num, last = account.failed_logins.as_tuple()
294        #if (num > 2) and (time.time() < (last + 2**(num-3))):
295        #    # tried login while account still blocked due to previous
296        #    # login errors.
297        #    return None
[4073]298        if not account.checkPassword(credentials['password']):
[10055]299            #account.failed_logins.increase()
[4073]300            return None
[7819]301        return KofaPrincipalInfo(
[7315]302            id=account.name,
303            title=account.title,
304            description=account.description,
305            email=account.email,
306            phone=account.phone,
[8757]307            public_name=account.public_name,
[7315]308            user_type=u'user')
[4073]309
310    def principalInfo(self, id):
311        account = self.getAccount(id)
312        if account is None:
313            return None
[7819]314        return KofaPrincipalInfo(
[7315]315            id=account.name,
316            title=account.title,
317            description=account.description,
318            email=account.email,
319            phone=account.phone,
[8757]320            public_name=account.public_name,
[7315]321            user_type=u'user')
[4073]322
323    def getAccount(self, login):
[4087]324        # ... look up the account object and return it ...
[7172]325        userscontainer = self.getUsersContainer()
326        if userscontainer is None:
[4087]327            return
[7172]328        return userscontainer.get(login, None)
[4087]329
330    def addAccount(self, account):
[7172]331        userscontainer = self.getUsersContainer()
332        if userscontainer is None:
[4087]333            return
334        # XXX: complain if name already exists...
[7172]335        userscontainer.addAccount(account)
[4087]336
[4091]337    def addUser(self, name, password, title=None, description=None):
[7172]338        userscontainer = self.getUsersContainer()
339        if userscontainer is None:
[4091]340            return
[7172]341        userscontainer.addUser(name, password, title, description)
[5055]342
[7172]343    def getUsersContainer(self):
[4087]344        site = grok.getSite()
[4743]345        return site['users']
[6203]346
[7147]347class PasswordValidator(grok.GlobalUtility):
348
349  grok.implements(IPasswordValidator)
350
351  def validate_password(self, pw, pw_repeat):
352       errors = []
353       if len(pw) < 3:
354         errors.append('Password must have at least 3 chars.')
355       if pw != pw_repeat:
356         errors.append('Passwords do not match.')
357       return errors
358
[7169]359class LocalRoleSetEvent(object):
360
361    grok.implements(ILocalRoleSetEvent)
362
363    def __init__(self, object, role_id, principal_id, granted=True):
364        self.object = object
365        self.role_id = role_id
366        self.principal_id = principal_id
367        self.granted = granted
368
[6203]369@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
[6839]370def handle_account_removed(account, event):
[9313]371    """When an account is removed, local and global roles might
372    have to be deleted.
[6203]373    """
374    local_roles = account.getLocalRoles()
375    principal = account.name
[9313]376
[6203]377    for role_id, object_list in local_roles.items():
378        for object in object_list:
379            try:
380                role_manager = IPrincipalRoleManager(object)
381            except TypeError:
[9313]382                # No Account object, no role manager, no local roles to remove
[6203]383                continue
384            role_manager.unsetRoleForPrincipal(role_id, principal)
[9313]385    role_manager = IPrincipalRoleManager(grok.getSite())
386    roles = account.getSiteRolesForPrincipal()
387    for role_id in roles:
388        role_manager.unsetRoleForPrincipal(role_id, principal)
[6203]389    return
[7169]390
391@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
392def handle_account_added(account, event):
393    """When an account is added, the local owner role and the global
[7185]394    AcademicsOfficer role must be set.
[7169]395    """
[7173]396    # We set the local Owner role
397    role_manager_account = IPrincipalRoleManager(account)
398    role_manager_account.assignRoleToPrincipal(
[7169]399        'waeup.local.Owner', account.name)
[7185]400    # We set the global AcademicsOfficer role
[7173]401    site = grok.getSite()
402    role_manager_site = IPrincipalRoleManager(site)
403    role_manager_site.assignRoleToPrincipal(
[7185]404        'waeup.AcademicsOfficer', account.name)
[7173]405    # Finally we have to notify the user account that the local role
[7169]406    # of the same object has changed
407    notify(LocalRoleSetEvent(
408        account, 'waeup.local.Owner', account.name, granted=True))
409    return
410
411@grok.subscribe(Interface, ILocalRoleSetEvent)
412def handle_local_role_changed(obj, event):
413    site = grok.getSite()
414    if site is None:
415        return
416    users = site.get('users', None)
417    if users is None:
418        return
419    if event.principal_id not in users.keys():
420        return
421    user = users[event.principal_id]
422    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
423    return
424
425@grok.subscribe(Interface, grok.IObjectRemovedEvent)
426def handle_local_roles_on_obj_removed(obj, event):
427    try:
428        role_map = IPrincipalRoleMap(obj)
429    except TypeError:
430        # no map, no roles to remove
431        return
432    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
433        notify(LocalRoleSetEvent(
434                obj, local_role, user_name, granted=False))
[7315]435    return
[8756]436
[8973]437class UserAccountFactory(grok.GlobalUtility):
438    """A factory for user accounts.
439
440    This factory is only needed for imports.
441    """
442    grok.implements(IFactory)
443    grok.name(u'waeup.UserAccount')
444    title = u"Create a user.",
445    description = u"This factory instantiates new user account instances."
446
447    def __call__(self, *args, **kw):
448        return Account(name=None, password='')
449
450    def getInterfaces(self):
451        return implementedBy(Account)
452
453class UserProcessor(BatchProcessor):
[12869]454    """The User Processor processes user accounts, i.e. `Account` objects in the
455    ``users`` container.
456
457    The `roles` columns must contain Python list
458    expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``.
459
460    The processor does not import local roles. These can be imported
461    by means of batch processors in the academic section.
[8973]462    """
463    grok.implements(IBatchProcessor)
464    grok.provides(IBatchProcessor)
465    grok.context(Interface)
466    util_name = 'userprocessor'
467    grok.name(util_name)
468
469    name = u'User Processor'
470    iface = IUserAccount
471
472    location_fields = ['name',]
473    factory_name = 'waeup.UserAccount'
474
475    mode = None
476
477    def parentsExist(self, row, site):
478        return 'users' in site.keys()
479
480    def entryExists(self, row, site):
481        return row['name'] in site['users'].keys()
482
483    def getParent(self, row, site):
484        return site['users']
485
486    def getEntry(self, row, site):
487        if not self.entryExists(row, site):
488            return None
489        parent = self.getParent(row, site)
490        return parent.get(row['name'])
491
492    def addEntry(self, obj, row, site):
493        parent = self.getParent(row, site)
494        parent.addAccount(obj)
495        return
496
[9706]497    def updateEntry(self, obj, row, site, filename):
[9312]498        """Update obj to the values given in row.
499        """
500        changed = []
501        for key, value in row.items():
502            if  key == 'roles':
503                # We cannot simply set the roles attribute here because
504                # we can't assure that the name attribute is set before
505                # the roles attribute is set.
506                continue
507            # Skip fields to be ignored.
508            if value == IGNORE_MARKER:
509                continue
510            if not hasattr(obj, key):
511                continue
512            setattr(obj, key, value)
513            changed.append('%s=%s' % (key, value))
514        roles = row.get('roles', IGNORE_MARKER)
515        if roles not in ('', IGNORE_MARKER):
516            evalvalue = eval(roles)
517            if isinstance(evalvalue, list):
518                setattr(obj, 'roles', evalvalue)
519                changed.append('roles=%s' % roles)
520        # Log actions...
521        items_changed = ', '.join(changed)
[9706]522        grok.getSite().logger.info('%s - %s - %s - updated: %s'
523            % (self.name, filename, row['name'], items_changed))
[9312]524        return
[8973]525
[12190]526    def checkConversion(self, row, mode='ignore'):
527        """Validates all values in row.
528        """
529        errs, inv_errs, conv_dict = super(
530            UserProcessor, self).checkConversion(row, mode=mode)
531        # We need to check if roles exist.
532        roles = row.get('roles', None)
533        all_roles = [i[0] for i in get_all_roles()]
534        if roles not in ('', IGNORE_MARKER):
535            evalvalue = eval(roles)
536            for role in evalvalue:
537                if role not in all_roles:
538                    errs.append(('roles','invalid role'))
539        return errs, inv_errs, conv_dict
540
[8756]541class UsersPlugin(grok.GlobalUtility):
542    """A plugin that updates users.
543    """
544    grok.implements(IKofaPluggable)
545    grok.name('users')
546
547    deprecated_attributes = []
548
549    def setup(self, site, name, logger):
550        return
551
552    def update(self, site, name, logger):
553        users = site['users']
554        items = getFields(IUserAccount).items()
555        for user in users.values():
556            # Add new attributes
557            for i in items:
558                if not hasattr(user,i[0]):
559                    setattr(user,i[0],i[1].missing_value)
560                    logger.info(
561                        'UsersPlugin: %s attribute %s added.' % (
562                        user.name,i[0]))
[10055]563            if not hasattr(user, 'failed_logins'):
564                # add attribute `failed_logins`...
565                user.failed_logins = FailedLoginInfo()
566                logger.info(
567                    'UsersPlugin: attribute failed_logins added.')
[8756]568            # Remove deprecated attributes
569            for i in self.deprecated_attributes:
570                try:
571                    delattr(user,i)
572                    logger.info(
573                        'UsersPlugin: %s attribute %s deleted.' % (
574                        user.name,i))
575                except AttributeError:
576                    pass
[10055]577        return
Note: See TracBrowser for help on using the repository browser.