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

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

Add XMLRPC-aware auth credentials plugin.

This new plugin can be used to authenticate users in a site
(i.e. normally officers of a University instance) with
regular HTTP basic auth credentials (normally we expect a
web form, where credentials are sent as form-vars).

This plugin is registered in _new_ University instances
automatically, but it is _not_ registered with already
existing PAUs.

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