source: main/waeup.kofa/branches/henrik-transcript-workflow/src/waeup/kofa/authentication.py @ 16536

Last change on this file since 16536 was 14670, checked in by uli, 8 years ago

Add IKofaPluggable to update local PAU.

The new plugin enables updating of sites that have
yet no XMLRPC authentication enabled.

The plugin can be removed after updating all sites.

New sites (university-instances) do not need this
plugin at all.

  • Property svn:keywords set to Id
File size: 22.2 KB
Line 
1## $Id: authentication.py 14670 2017-04-06 12:20:41Z uli $
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.plugins.httpplugins import (
32    HTTPBasicAuthCredentialsPlugin)
33from zope.pluggableauth.interfaces import (
34    ICredentialsPlugin, IAuthenticatorPlugin, IAuthenticatedPrincipalFactory,
35    AuthenticatedPrincipalCreated)
36from zope.publisher.interfaces import IRequest
37from zope.password.interfaces import IPasswordManager
38from zope.securitypolicy.principalrole import principalRoleManager
39from waeup.kofa.interfaces import (
40    ILocalRoleSetEvent, IUserAccount, IAuthPluginUtility, IPasswordValidator,
41    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable, IBatchProcessor,
42    IGNORE_MARKER, IFailedLoginInfo)
43from waeup.kofa.utils.batching import BatchProcessor
44from waeup.kofa.permissions import get_all_roles
45
46
47def setup_authentication(pau):
48    """Set up plugguble authentication utility.
49
50    Sets up an IAuthenticatorPlugin and
51    ICredentialsPlugin (for the authentication mechanism)
52
53    Then looks for any external utilities that want to modify the PAU.
54    """
55    pau.credentialsPlugins = (
56        'No Challenge if Authenticated',
57        'xmlrpc-credentials',
58        'credentials')
59    pau.authenticatorPlugins = ('users',)
60
61    # Give any third-party code and subpackages a chance to modify the PAU
62    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
63    for name, util in auth_plugin_utilities:
64        util.register(pau)
65
66
67def get_principal_role_manager():
68    """Get a role manager for principals.
69
70    If we are currently 'in a site', return the role manager for the
71    portal or the global rolemanager else.
72    """
73    portal = grok.getSite()
74    if portal is not None:
75        return IPrincipalRoleManager(portal)
76    return principalRoleManager
77
78
79class KofaSessionCredentialsPlugin(
80        grok.GlobalUtility, SessionCredentialsPlugin):
81    """Session plugin that picks usernames/passwords from fields in webforms.
82    """
83    grok.provides(ICredentialsPlugin)
84    grok.name('credentials')
85
86    loginpagename = 'login'
87    loginfield = 'form.login'
88    passwordfield = 'form.password'
89
90
91class KofaXMLRPCCredentialsPlugin(
92        grok.GlobalUtility, HTTPBasicAuthCredentialsPlugin):
93    """Plugin that picks useranams/passwords from basic-auth headers.
94
95    As XMLRPC requests send/post their authentication credentials in HTTP
96    basic-auth headers, we need a plugin that can handle this.
97
98    This plugin, however, does no challenging. If a user does not provide
99    basic-auth infos, we will not ask for some. This is correct as we plan to
100    communicate with machines.
101
102    This plugin is planned to be used in "PluggableAuthenitications" registered
103    with `University` instances.
104    """
105    grok.provides(ICredentialsPlugin)
106    grok.name('xmlrpc-credentials')
107
108    def challenge(self, request):
109        """XMLRPC is for machines. No need to challenge.
110        """
111        return False
112
113    def logout(self, request):
114        """Basic auth does not provide any logout possibility.
115        """
116        return False
117
118
119class KofaPrincipalInfo(object):
120    """An implementation of IKofaPrincipalInfo.
121
122    A Kofa principal info is created with id, login, title, description,
123    phone, email, public_name and user_type.
124    """
125    grok.implements(IKofaPrincipalInfo)
126
127    def __init__(self, id, title, description, email, phone, public_name,
128                 user_type):
129        self.id = id
130        self.title = title
131        self.description = description
132        self.email = email
133        self.phone = phone
134        self.public_name = public_name
135        self.user_type = user_type
136        self.credentialsPlugin = None
137        self.authenticatorPlugin = None
138
139    def __eq__(self, obj):
140        default = object()
141        result = []
142        for name in ('id', 'title', 'description', 'email', 'phone',
143                     'public_name', 'user_type', 'credentialsPlugin',
144                     'authenticatorPlugin'):
145            result.append(
146                getattr(self, name) == getattr(obj, name, default))
147        return False not in result
148
149
150class KofaPrincipal(Principal):
151    """A portal principal.
152
153    Kofa principals provide an extra `email`, `phone`, `public_name`
154    and `user_type` attribute extending ordinary principals.
155    """
156
157    grok.implements(IKofaPrincipal)
158
159    def __init__(self, id, title=u'', description=u'', email=u'',
160                 phone=None, public_name=u'', user_type=u'', prefix=None):
161        self.id = id
162        if prefix is not None:
163            self.id = '%s.%s' % (prefix, self.id)
164        self.title = title
165        self.description = description
166        self.groups = []
167        self.email = email
168        self.phone = phone
169        self.public_name = public_name
170        self.user_type = user_type
171
172    def __repr__(self):
173        return 'KofaPrincipal(%r)' % self.id
174
175
176class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
177    """Creates 'authenticated' Kofa principals.
178
179    Adapts (principal info, request) to a KofaPrincipal instance.
180
181    This adapter is used by the standard PAU to transform
182    KofaPrincipalInfos into KofaPrincipal instances.
183    """
184    grok.adapts(IKofaPrincipalInfo, IRequest)
185    grok.implements(IAuthenticatedPrincipalFactory)
186
187    def __init__(self, info, request):
188        self.info = info
189        self.request = request
190
191    def __call__(self, authentication):
192        principal = KofaPrincipal(
193            self.info.id,
194            self.info.title,
195            self.info.description,
196            self.info.email,
197            self.info.phone,
198            self.info.public_name,
199            self.info.user_type,
200            authentication.prefix,
201            )
202        notify(
203            AuthenticatedPrincipalCreated(
204                authentication, principal, self.info, self.request))
205        return principal
206
207
208class FailedLoginInfo(grok.Model):
209    grok.implements(IFailedLoginInfo)
210
211    def __init__(self, num=0, last=None):
212        self.num = num
213        self.last = last
214        return
215
216    def as_tuple(self):
217        return (self.num, self.last)
218
219    def set_values(self, num=0, last=None):
220        self.num, self.last = num, last
221        self._p_changed = True
222        pass
223
224    def increase(self):
225        self.set_values(num=self.num + 1, last=time.time())
226        pass
227
228    def reset(self):
229        self.set_values(num=0, last=None)
230        pass
231
232
233class Account(grok.Model):
234    """Kofa user accounts store infos about a user.
235
236    Beside the usual data and an (encrypted) password, accounts also
237    have a persistent attribute `failed_logins` which is an instance
238    of `waeup.kofa.authentication.FailedLoginInfo`.
239
240    This attribute can be manipulated directly (set new value,
241    increase values, or reset).
242    """
243    grok.implements(IUserAccount)
244
245    def __init__(self, name, password, title=None, description=None,
246                 email=None, phone=None, public_name=None, roles=[]):
247        self.name = name
248        if title is None:
249            title = name
250        self.title = title
251        self.description = description
252        self.email = email
253        self.phone = phone
254        self.public_name = public_name
255        self.suspended = False
256        self.setPassword(password)
257        self.setSiteRolesForPrincipal(roles)
258
259        # We don't want to share this dict with other accounts
260        self._local_roles = dict()
261        self.failed_logins = FailedLoginInfo()
262
263    def setPassword(self, password):
264        passwordmanager = getUtility(IPasswordManager, 'SSHA')
265        self.password = passwordmanager.encodePassword(password)
266
267    def checkPassword(self, password):
268        try:
269            blocker = grok.getSite()['configuration'].maintmode_enabled_by
270            if blocker and blocker != self.name:
271                return False
272        except (TypeError, KeyError):  # in unit tests
273            pass
274        if not isinstance(password, basestring):
275            return False
276        if not self.password:
277            # unset/empty passwords do never match
278            return False
279        if self.suspended:
280            return False
281        passwordmanager = getUtility(IPasswordManager, 'SSHA')
282        return passwordmanager.checkPassword(self.password, password)
283
284    def getSiteRolesForPrincipal(self):
285        prm = get_principal_role_manager()
286        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
287                 if x[0].startswith('waeup.')]
288        return roles
289
290    def setSiteRolesForPrincipal(self, roles):
291        prm = get_principal_role_manager()
292        old_roles = self.getSiteRolesForPrincipal()
293        if sorted(old_roles) == sorted(roles):
294            return
295        for role in old_roles:
296            # Remove old roles, not to be set now...
297            if role.startswith('waeup.') and role not in roles:
298                prm.unsetRoleForPrincipal(role, self.name)
299        for role in roles:
300            # Convert role to ASCII string to be in line with the
301            # event handler
302            prm.assignRoleToPrincipal(str(role), self.name)
303        return
304
305    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
306
307    def getLocalRoles(self):
308        return self._local_roles
309
310    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
311        objects = self._local_roles.get(role_id, [])
312        if granted and obj not in objects:
313            objects.append(obj)
314        if not granted and obj in objects:
315            objects.remove(obj)
316        self._local_roles[role_id] = objects
317        if len(objects) == 0:
318            del self._local_roles[role_id]
319        self._p_changed = True
320        return
321
322
323class UserAuthenticatorPlugin(grok.GlobalUtility):
324    grok.implements(IAuthenticatorPlugin)
325    grok.provides(IAuthenticatorPlugin)
326    grok.name('users')
327
328    def authenticateCredentials(self, credentials):
329        if not isinstance(credentials, dict):
330            return None
331        if not ('login' in credentials and 'password' in credentials):
332            return None
333        account = self.getAccount(credentials['login'])
334        if account is None:
335            return None
336        # The following shows how 'time penalties' could be enforced
337        # on failed logins. First three failed logins are 'for
338        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
339        # 32, ... seconds before a login can succeed.
340        # There are, however, some problems to discuss, before we
341        # really use this in all authenticators.
342
343        #num, last = account.failed_logins.as_tuple()
344        #if (num > 2) and (time.time() < (last + 2**(num-3))):
345        #    # tried login while account still blocked due to previous
346        #    # login errors.
347        #    return None
348        if not account.checkPassword(credentials['password']):
349            #account.failed_logins.increase()
350            return None
351        return KofaPrincipalInfo(
352            id=account.name,
353            title=account.title,
354            description=account.description,
355            email=account.email,
356            phone=account.phone,
357            public_name=account.public_name,
358            user_type=u'user')
359
360    def principalInfo(self, id):
361        account = self.getAccount(id)
362        if account is None:
363            return None
364        return KofaPrincipalInfo(
365            id=account.name,
366            title=account.title,
367            description=account.description,
368            email=account.email,
369            phone=account.phone,
370            public_name=account.public_name,
371            user_type=u'user')
372
373    def getAccount(self, login):
374        # ... look up the account object and return it ...
375        userscontainer = self.getUsersContainer()
376        if userscontainer is None:
377            return
378        return userscontainer.get(login, None)
379
380    def addAccount(self, account):
381        userscontainer = self.getUsersContainer()
382        if userscontainer is None:
383            return
384        # XXX: complain if name already exists...
385        userscontainer.addAccount(account)
386
387    def addUser(self, name, password, title=None, description=None):
388        userscontainer = self.getUsersContainer()
389        if userscontainer is None:
390            return
391        userscontainer.addUser(name, password, title, description)
392
393    def getUsersContainer(self):
394        site = grok.getSite()
395        return site['users']
396
397
398class PasswordValidator(grok.GlobalUtility):
399
400    grok.implements(IPasswordValidator)
401
402    def validate_password(self, pw, pw_repeat):
403        errors = []
404        if len(pw) < 3:
405            errors.append('Password must have at least 3 chars.')
406        if pw != pw_repeat:
407            errors.append('Passwords do not match.')
408        return errors
409
410
411class LocalRoleSetEvent(object):
412
413    grok.implements(ILocalRoleSetEvent)
414
415    def __init__(self, object, role_id, principal_id, granted=True):
416        self.object = object
417        self.role_id = role_id
418        self.principal_id = principal_id
419        self.granted = granted
420
421
422@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
423def handle_account_removed(account, event):
424    """When an account is removed, local and global roles might
425    have to be deleted.
426    """
427    local_roles = account.getLocalRoles()
428    principal = account.name
429
430    for role_id, object_list in local_roles.items():
431        for object in object_list:
432            try:
433                role_manager = IPrincipalRoleManager(object)
434            except TypeError:
435                # No Account object, no role manager, no local roles to remove
436                continue
437            role_manager.unsetRoleForPrincipal(role_id, principal)
438    role_manager = IPrincipalRoleManager(grok.getSite())
439    roles = account.getSiteRolesForPrincipal()
440    for role_id in roles:
441        role_manager.unsetRoleForPrincipal(role_id, principal)
442    return
443
444
445@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
446def handle_account_added(account, event):
447    """When an account is added, the local owner role and the global
448    AcademicsOfficer role must be set.
449    """
450    # We set the local Owner role
451    role_manager_account = IPrincipalRoleManager(account)
452    role_manager_account.assignRoleToPrincipal(
453        'waeup.local.Owner', account.name)
454    # We set the global AcademicsOfficer role
455    site = grok.getSite()
456    role_manager_site = IPrincipalRoleManager(site)
457    role_manager_site.assignRoleToPrincipal(
458        'waeup.AcademicsOfficer', account.name)
459    # Finally we have to notify the user account that the local role
460    # of the same object has changed
461    notify(LocalRoleSetEvent(
462        account, 'waeup.local.Owner', account.name, granted=True))
463    return
464
465
466@grok.subscribe(Interface, ILocalRoleSetEvent)
467def handle_local_role_changed(obj, event):
468    site = grok.getSite()
469    if site is None:
470        return
471    users = site.get('users', None)
472    if users is None:
473        return
474    if event.principal_id not in users.keys():
475        return
476    user = users[event.principal_id]
477    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
478    return
479
480
481@grok.subscribe(Interface, grok.IObjectRemovedEvent)
482def handle_local_roles_on_obj_removed(obj, event):
483    try:
484        role_map = IPrincipalRoleMap(obj)
485    except TypeError:
486        # no map, no roles to remove
487        return
488    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
489        notify(LocalRoleSetEvent(
490            obj, local_role, user_name, granted=False))
491    return
492
493
494class UserAccountFactory(grok.GlobalUtility):
495    """A factory for user accounts.
496
497    This factory is only needed for imports.
498    """
499    grok.implements(IFactory)
500    grok.name(u'waeup.UserAccount')
501    title = u"Create a user.",
502    description = u"This factory instantiates new user account instances."
503
504    def __call__(self, *args, **kw):
505        return Account(name=None, password='')
506
507    def getInterfaces(self):
508        return implementedBy(Account)
509
510
511class UserProcessor(BatchProcessor):
512    """The User Processor processes user accounts, i.e. `Account` objects in
513    the ``users`` container.
514
515    The `roles` columns must contain Python list
516    expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``.
517
518    The processor does not import local roles. These can be imported
519    by means of batch processors in the academic section.
520    """
521    grok.implements(IBatchProcessor)
522    grok.provides(IBatchProcessor)
523    grok.context(Interface)
524    util_name = 'userprocessor'
525    grok.name(util_name)
526
527    name = u'User Processor'
528    iface = IUserAccount
529
530    location_fields = ['name', ]
531    factory_name = 'waeup.UserAccount'
532
533    mode = None
534
535    def parentsExist(self, row, site):
536        return 'users' in site.keys()
537
538    def entryExists(self, row, site):
539        return row['name'] in site['users'].keys()
540
541    def getParent(self, row, site):
542        return site['users']
543
544    def getEntry(self, row, site):
545        if not self.entryExists(row, site):
546            return None
547        parent = self.getParent(row, site)
548        return parent.get(row['name'])
549
550    def addEntry(self, obj, row, site):
551        parent = self.getParent(row, site)
552        parent.addAccount(obj)
553        return
554
555    def delEntry(self, row, site):
556        user = self.getEntry(row, site)
557        if user is not None:
558            parent = self.getParent(row, site)
559            grok.getSite().logger.info(
560                '%s - %s - User removed' % (self.name, row['name']))
561            del parent[user.name]
562        pass
563
564    def updateEntry(self, obj, row, site, filename):
565        """Update obj to the values given in row.
566        """
567        changed = []
568        for key, value in row.items():
569            if key == 'roles':
570                # We cannot simply set the roles attribute here because
571                # we can't assure that the name attribute is set before
572                # the roles attribute is set.
573                continue
574            # Skip fields to be ignored.
575            if value == IGNORE_MARKER:
576                continue
577            if not hasattr(obj, key):
578                continue
579            setattr(obj, key, value)
580            changed.append('%s=%s' % (key, value))
581        roles = row.get('roles', IGNORE_MARKER)
582        if roles not in ('', IGNORE_MARKER):
583            evalvalue = eval(roles)
584            if isinstance(evalvalue, list):
585                setattr(obj, 'roles', evalvalue)
586                changed.append('roles=%s' % roles)
587        # Log actions...
588        items_changed = ', '.join(changed)
589        grok.getSite().logger.info(
590            '%s - %s - %s - updated: %s' % (
591                self.name, filename, row['name'], items_changed))
592        return
593
594    def checkConversion(self, row, mode='ignore'):
595        """Validates all values in row.
596        """
597        errs, inv_errs, conv_dict = super(
598            UserProcessor, self).checkConversion(row, mode=mode)
599        # We need to check if roles exist.
600        roles = row.get('roles', IGNORE_MARKER)
601        all_roles = [i[0] for i in get_all_roles()]
602        if roles not in ('', IGNORE_MARKER):
603            evalvalue = eval(roles)
604            for role in evalvalue:
605                if role not in all_roles:
606                    errs.append(('roles', 'invalid role'))
607        return errs, inv_errs, conv_dict
608
609
610class UsersPlugin(grok.GlobalUtility):
611    """A plugin that updates users.
612    """
613    grok.implements(IKofaPluggable)
614    grok.name('users')
615
616    deprecated_attributes = []
617
618    def setup(self, site, name, logger):
619        return
620
621    def update(self, site, name, logger):
622        users = site['users']
623        items = getFields(IUserAccount).items()
624        for user in users.values():
625            # Add new attributes
626            for i in items:
627                if not hasattr(user, i[0]):
628                    setattr(user, i[0], i[1].missing_value)
629                    logger.info(
630                        'UsersPlugin: %s attribute %s added.' % (
631                            user.name, i[0]))
632            if not hasattr(user, 'failed_logins'):
633                # add attribute `failed_logins`...
634                user.failed_logins = FailedLoginInfo()
635                logger.info(
636                    'UsersPlugin: attribute failed_logins added.')
637            # Remove deprecated attributes
638            for i in self.deprecated_attributes:
639                try:
640                    delattr(user, i)
641                    logger.info(
642                        'UsersPlugin: %s attribute %s deleted.' % (
643                            user.name, i))
644                except AttributeError:
645                    pass
646        return
647
648
649class UpdatePAUPlugin(grok.GlobalUtility):
650    """A plugin that updates a local PAU.
651
652    We insert an 'xmlrpc-credentials' PAU-plugin into a sites PAU if it is not
653    present already. There must be 'credentials' plugin registered already.
654
655    XXX: This Plugin fixes a shortcoming of waeup.kofa 1.5. Sites created or
656         updated afterwards do not need this plugin and it should be removed.
657    """
658    grok.implements(IKofaPluggable)
659    grok.name('site-pluggable-auth')
660
661    def setup(self, site, name, logger):
662        return
663
664    def update(self, site, name, logger):
665        pau = site.getSiteManager()['PluggableAuthentication']
666        if 'xmlrpc-credentials' in pau.credentialsPlugins:
667            return
668        plugins = list(pau.credentialsPlugins)
669        plugins.insert(plugins.index('credentials'), 'xmlrpc-credentials')
670        pau.credentialsPlugins = tuple(plugins)
Note: See TracBrowser for help on using the repository browser.