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
Line 
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##
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        if not isinstance(password, basestring):
226            return False
227        if not self.password:
228            # unset/empty passwords do never match
229            return False
230        if self.suspended == True:
231            return False
232        passwordmanager = getUtility(IPasswordManager, 'SSHA')
233        return passwordmanager.checkPassword(self.password, password)
234
235    def getSiteRolesForPrincipal(self):
236        prm = get_principal_role_manager()
237        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
238                 if x[0].startswith('waeup.')]
239        return roles
240
241    def setSiteRolesForPrincipal(self, roles):
242        prm = get_principal_role_manager()
243        old_roles = self.getSiteRolesForPrincipal()
244        if sorted(old_roles) == sorted(roles):
245            return
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:
251            # Convert role to ASCII string to be in line with the
252            # event handler
253            prm.assignRoleToPrincipal(str(role), self.name)
254        return
255
256    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
257
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]
270        self._p_changed = True
271        return
272
273class UserAuthenticatorPlugin(grok.GlobalUtility):
274    grok.implements(IAuthenticatorPlugin)
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
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
298        if not account.checkPassword(credentials['password']):
299            #account.failed_logins.increase()
300            return None
301        return KofaPrincipalInfo(
302            id=account.name,
303            title=account.title,
304            description=account.description,
305            email=account.email,
306            phone=account.phone,
307            public_name=account.public_name,
308            user_type=u'user')
309
310    def principalInfo(self, id):
311        account = self.getAccount(id)
312        if account is None:
313            return None
314        return KofaPrincipalInfo(
315            id=account.name,
316            title=account.title,
317            description=account.description,
318            email=account.email,
319            phone=account.phone,
320            public_name=account.public_name,
321            user_type=u'user')
322
323    def getAccount(self, login):
324        # ... look up the account object and return it ...
325        userscontainer = self.getUsersContainer()
326        if userscontainer is None:
327            return
328        return userscontainer.get(login, None)
329
330    def addAccount(self, account):
331        userscontainer = self.getUsersContainer()
332        if userscontainer is None:
333            return
334        # XXX: complain if name already exists...
335        userscontainer.addAccount(account)
336
337    def addUser(self, name, password, title=None, description=None):
338        userscontainer = self.getUsersContainer()
339        if userscontainer is None:
340            return
341        userscontainer.addUser(name, password, title, description)
342
343    def getUsersContainer(self):
344        site = grok.getSite()
345        return site['users']
346
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
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
369@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
370def handle_account_removed(account, event):
371    """When an account is removed, local and global roles might
372    have to be deleted.
373    """
374    local_roles = account.getLocalRoles()
375    principal = account.name
376
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:
382                # No Account object, no role manager, no local roles to remove
383                continue
384            role_manager.unsetRoleForPrincipal(role_id, principal)
385    role_manager = IPrincipalRoleManager(grok.getSite())
386    roles = account.getSiteRolesForPrincipal()
387    for role_id in roles:
388        role_manager.unsetRoleForPrincipal(role_id, principal)
389    return
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
394    AcademicsOfficer role must be set.
395    """
396    # We set the local Owner role
397    role_manager_account = IPrincipalRoleManager(account)
398    role_manager_account.assignRoleToPrincipal(
399        'waeup.local.Owner', account.name)
400    # We set the global AcademicsOfficer role
401    site = grok.getSite()
402    role_manager_site = IPrincipalRoleManager(site)
403    role_manager_site.assignRoleToPrincipal(
404        'waeup.AcademicsOfficer', account.name)
405    # Finally we have to notify the user account that the local role
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))
435    return
436
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):
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.
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
497    def updateEntry(self, obj, row, site, filename):
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)
522        grok.getSite().logger.info('%s - %s - %s - updated: %s'
523            % (self.name, filename, row['name'], items_changed))
524        return
525
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
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]))
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.')
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
577        return
Note: See TracBrowser for help on using the repository browser.