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

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

Implement portal maintenance mode.

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