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

Last change on this file since 13699 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
Line 
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##
18"""Authentication for Kofa.
19"""
20import grok
21import time
22from zope.event import notify
23from zope.component import getUtility, getUtilitiesFor
24from zope.component.interfaces import IFactory
25from zope.interface import Interface, implementedBy
26from zope.schema import getFields
27from zope.securitypolicy.interfaces import (
28    IPrincipalRoleMap, IPrincipalRoleManager)
29from zope.pluggableauth.factories import Principal
30from zope.pluggableauth.plugins.session import SessionCredentialsPlugin
31from zope.pluggableauth.interfaces import (
32        ICredentialsPlugin, IAuthenticatorPlugin,
33        IAuthenticatedPrincipalFactory, AuthenticatedPrincipalCreated)
34from zope.publisher.interfaces import IRequest
35from zope.password.interfaces import IPasswordManager
36from zope.securitypolicy.principalrole import principalRoleManager
37from waeup.kofa.interfaces import (ILocalRoleSetEvent,
38    IUserAccount, IAuthPluginUtility, IPasswordValidator,
39    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable,
40    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
41from waeup.kofa.utils.batching import BatchProcessor
42from waeup.kofa.permissions import get_all_roles
43
44def setup_authentication(pau):
45    """Set up plugguble authentication utility.
46
47    Sets up an IAuthenticatorPlugin and
48    ICredentialsPlugin (for the authentication mechanism)
49
50    Then looks for any external utilities that want to modify the PAU.
51    """
52    pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials')
53    pau.authenticatorPlugins = ('users',)
54
55    # Give any third-party code and subpackages a chance to modify the PAU
56    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
57    for name, util in auth_plugin_utilities:
58        util.register(pau)
59
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
71class KofaSessionCredentialsPlugin(grok.GlobalUtility,
72                                    SessionCredentialsPlugin):
73    grok.provides(ICredentialsPlugin)
74    grok.name('credentials')
75
76    loginpagename = 'login'
77    loginfield = 'form.login'
78    passwordfield = 'form.password'
79
80class KofaPrincipalInfo(object):
81    """An implementation of IKofaPrincipalInfo.
82
83    A Kofa principal info is created with id, login, title, description,
84    phone, email, public_name and user_type.
85    """
86    grok.implements(IKofaPrincipalInfo)
87
88    def __init__(self, id, title, description, email, phone, public_name,
89                 user_type):
90        self.id = id
91        self.title = title
92        self.description = description
93        self.email = email
94        self.phone = phone
95        self.public_name = public_name
96        self.user_type = user_type
97        self.credentialsPlugin = None
98        self.authenticatorPlugin = None
99
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
110class KofaPrincipal(Principal):
111    """A portal principal.
112
113    Kofa principals provide an extra `email`, `phone`, `public_name`
114    and `user_type` attribute extending ordinary principals.
115    """
116
117    grok.implements(IKofaPrincipal)
118
119    def __init__(self, id, title=u'', description=u'', email=u'',
120                 phone=None, public_name=u'', user_type=u'', prefix=None):
121        self.id = id
122        if prefix is not None:
123            self.id = '%s.%s' % (prefix, self.id)
124        self.title = title
125        self.description = description
126        self.groups = []
127        self.email = email
128        self.phone = phone
129        self.public_name = public_name
130        self.user_type = user_type
131
132    def __repr__(self):
133        return 'KofaPrincipal(%r)' % self.id
134
135class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
136    """Creates 'authenticated' Kofa principals.
137
138    Adapts (principal info, request) to a KofaPrincipal instance.
139
140    This adapter is used by the standard PAU to transform
141    KofaPrincipalInfos into KofaPrincipal instances.
142    """
143    grok.adapts(IKofaPrincipalInfo, IRequest)
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):
151        principal = KofaPrincipal(
152            self.info.id,
153            self.info.title,
154            self.info.description,
155            self.info.email,
156            self.info.phone,
157            self.info.public_name,
158            self.info.user_type,
159            authentication.prefix,
160            )
161        notify(
162            AuthenticatedPrincipalCreated(
163                authentication, principal, self.info, self.request))
164        return principal
165
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
190class Account(grok.Model):
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    """
200    grok.implements(IUserAccount)
201
202    def __init__(self, name, password, title=None, description=None,
203                 email=None, phone=None, public_name=None, roles = []):
204        self.name = name
205        if title is None:
206            title = name
207        self.title = title
208        self.description = description
209        self.email = email
210        self.phone = phone
211        self.public_name = public_name
212        self.suspended = False
213        self.setPassword(password)
214        self.setSiteRolesForPrincipal(roles)
215
216        # We don't want to share this dict with other accounts
217        self._local_roles = dict()
218        self.failed_logins = FailedLoginInfo()
219
220    def setPassword(self, password):
221        passwordmanager = getUtility(IPasswordManager, 'SSHA')
222        self.password = passwordmanager.encodePassword(password)
223
224    def checkPassword(self, password):
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
231        if not isinstance(password, basestring):
232            return False
233        if not self.password:
234            # unset/empty passwords do never match
235            return False
236        if self.suspended == True:
237            return False
238        passwordmanager = getUtility(IPasswordManager, 'SSHA')
239        return passwordmanager.checkPassword(self.password, password)
240
241    def getSiteRolesForPrincipal(self):
242        prm = get_principal_role_manager()
243        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
244                 if x[0].startswith('waeup.')]
245        return roles
246
247    def setSiteRolesForPrincipal(self, roles):
248        prm = get_principal_role_manager()
249        old_roles = self.getSiteRolesForPrincipal()
250        if sorted(old_roles) == sorted(roles):
251            return
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:
257            # Convert role to ASCII string to be in line with the
258            # event handler
259            prm.assignRoleToPrincipal(str(role), self.name)
260        return
261
262    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
263
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]
276        self._p_changed = True
277        return
278
279class UserAuthenticatorPlugin(grok.GlobalUtility):
280    grok.implements(IAuthenticatorPlugin)
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
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
304        if not account.checkPassword(credentials['password']):
305            #account.failed_logins.increase()
306            return None
307        return KofaPrincipalInfo(
308            id=account.name,
309            title=account.title,
310            description=account.description,
311            email=account.email,
312            phone=account.phone,
313            public_name=account.public_name,
314            user_type=u'user')
315
316    def principalInfo(self, id):
317        account = self.getAccount(id)
318        if account is None:
319            return None
320        return KofaPrincipalInfo(
321            id=account.name,
322            title=account.title,
323            description=account.description,
324            email=account.email,
325            phone=account.phone,
326            public_name=account.public_name,
327            user_type=u'user')
328
329    def getAccount(self, login):
330        # ... look up the account object and return it ...
331        userscontainer = self.getUsersContainer()
332        if userscontainer is None:
333            return
334        return userscontainer.get(login, None)
335
336    def addAccount(self, account):
337        userscontainer = self.getUsersContainer()
338        if userscontainer is None:
339            return
340        # XXX: complain if name already exists...
341        userscontainer.addAccount(account)
342
343    def addUser(self, name, password, title=None, description=None):
344        userscontainer = self.getUsersContainer()
345        if userscontainer is None:
346            return
347        userscontainer.addUser(name, password, title, description)
348
349    def getUsersContainer(self):
350        site = grok.getSite()
351        return site['users']
352
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
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
375@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
376def handle_account_removed(account, event):
377    """When an account is removed, local and global roles might
378    have to be deleted.
379    """
380    local_roles = account.getLocalRoles()
381    principal = account.name
382
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:
388                # No Account object, no role manager, no local roles to remove
389                continue
390            role_manager.unsetRoleForPrincipal(role_id, principal)
391    role_manager = IPrincipalRoleManager(grok.getSite())
392    roles = account.getSiteRolesForPrincipal()
393    for role_id in roles:
394        role_manager.unsetRoleForPrincipal(role_id, principal)
395    return
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
400    AcademicsOfficer role must be set.
401    """
402    # We set the local Owner role
403    role_manager_account = IPrincipalRoleManager(account)
404    role_manager_account.assignRoleToPrincipal(
405        'waeup.local.Owner', account.name)
406    # We set the global AcademicsOfficer role
407    site = grok.getSite()
408    role_manager_site = IPrincipalRoleManager(site)
409    role_manager_site.assignRoleToPrincipal(
410        'waeup.AcademicsOfficer', account.name)
411    # Finally we have to notify the user account that the local role
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))
441    return
442
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):
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.
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
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
512    def updateEntry(self, obj, row, site, filename):
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)
537        grok.getSite().logger.info('%s - %s - %s - updated: %s'
538            % (self.name, filename, row['name'], items_changed))
539        return
540
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.
547        roles = row.get('roles', IGNORE_MARKER)
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
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]))
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.')
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
592        return
Note: See TracBrowser for help on using the repository browser.