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

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

Start documenting batch processors.

  • Property svn:keywords set to Id
File size: 19.6 KB
Line 
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##
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.setPassword(password)
213        self.setSiteRolesForPrincipal(roles)
214
215        # We don't want to share this dict with other accounts
216        self._local_roles = dict()
217        self.failed_logins = FailedLoginInfo()
218
219    def setPassword(self, password):
220        passwordmanager = getUtility(IPasswordManager, 'SSHA')
221        self.password = passwordmanager.encodePassword(password)
222
223    def checkPassword(self, password):
224        if not isinstance(password, basestring):
225            return False
226        if not self.password:
227            # unset/empty passwords do never match
228            return False
229        passwordmanager = getUtility(IPasswordManager, 'SSHA')
230        return passwordmanager.checkPassword(self.password, password)
231
232    def getSiteRolesForPrincipal(self):
233        prm = get_principal_role_manager()
234        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
235                 if x[0].startswith('waeup.')]
236        return roles
237
238    def setSiteRolesForPrincipal(self, roles):
239        prm = get_principal_role_manager()
240        old_roles = self.getSiteRolesForPrincipal()
241        if sorted(old_roles) == sorted(roles):
242            return
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:
248            # Convert role to ASCII string to be in line with the
249            # event handler
250            prm.assignRoleToPrincipal(str(role), self.name)
251        return
252
253    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
254
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]
267        self._p_changed = True
268        return
269
270class UserAuthenticatorPlugin(grok.GlobalUtility):
271    grok.implements(IAuthenticatorPlugin)
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
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
295        if not account.checkPassword(credentials['password']):
296            #account.failed_logins.increase()
297            return None
298        return KofaPrincipalInfo(
299            id=account.name,
300            title=account.title,
301            description=account.description,
302            email=account.email,
303            phone=account.phone,
304            public_name=account.public_name,
305            user_type=u'user')
306
307    def principalInfo(self, id):
308        account = self.getAccount(id)
309        if account is None:
310            return None
311        return KofaPrincipalInfo(
312            id=account.name,
313            title=account.title,
314            description=account.description,
315            email=account.email,
316            phone=account.phone,
317            public_name=account.public_name,
318            user_type=u'user')
319
320    def getAccount(self, login):
321        # ... look up the account object and return it ...
322        userscontainer = self.getUsersContainer()
323        if userscontainer is None:
324            return
325        return userscontainer.get(login, None)
326
327    def addAccount(self, account):
328        userscontainer = self.getUsersContainer()
329        if userscontainer is None:
330            return
331        # XXX: complain if name already exists...
332        userscontainer.addAccount(account)
333
334    def addUser(self, name, password, title=None, description=None):
335        userscontainer = self.getUsersContainer()
336        if userscontainer is None:
337            return
338        userscontainer.addUser(name, password, title, description)
339
340    def getUsersContainer(self):
341        site = grok.getSite()
342        return site['users']
343
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
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
366@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
367def handle_account_removed(account, event):
368    """When an account is removed, local and global roles might
369    have to be deleted.
370    """
371    local_roles = account.getLocalRoles()
372    principal = account.name
373
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:
379                # No Account object, no role manager, no local roles to remove
380                continue
381            role_manager.unsetRoleForPrincipal(role_id, principal)
382    role_manager = IPrincipalRoleManager(grok.getSite())
383    roles = account.getSiteRolesForPrincipal()
384    for role_id in roles:
385        role_manager.unsetRoleForPrincipal(role_id, principal)
386    return
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
391    AcademicsOfficer role must be set.
392    """
393    # We set the local Owner role
394    role_manager_account = IPrincipalRoleManager(account)
395    role_manager_account.assignRoleToPrincipal(
396        'waeup.local.Owner', account.name)
397    # We set the global AcademicsOfficer role
398    site = grok.getSite()
399    role_manager_site = IPrincipalRoleManager(site)
400    role_manager_site.assignRoleToPrincipal(
401        'waeup.AcademicsOfficer', account.name)
402    # Finally we have to notify the user account that the local role
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))
432    return
433
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):
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.
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
494    def updateEntry(self, obj, row, site, filename):
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)
519        grok.getSite().logger.info('%s - %s - %s - updated: %s'
520            % (self.name, filename, row['name'], items_changed))
521        return
522
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
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]))
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.')
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
574        return
Note: See TracBrowser for help on using the repository browser.