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
RevLine 
[7193]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##
[7819]18"""Authentication for Kofa.
[4073]19"""
20import grok
[10055]21import time
[7169]22from zope.event import notify
[5900]23from zope.component import getUtility, getUtilitiesFor
[8973]24from zope.component.interfaces import IFactory
[10055]25from zope.interface import Interface, implementedBy
[8756]26from zope.schema import getFields
[7169]27from zope.securitypolicy.interfaces import (
28    IPrincipalRoleMap, IPrincipalRoleManager)
[7233]29from zope.pluggableauth.factories import Principal
[6610]30from zope.pluggableauth.plugins.session import SessionCredentialsPlugin
[14667]31from zope.pluggableauth.plugins.httpplugins import (
32        HTTPBasicAuthCredentialsPlugin)
[6610]33from zope.pluggableauth.interfaces import (
[7233]34        ICredentialsPlugin, IAuthenticatorPlugin,
35        IAuthenticatedPrincipalFactory, AuthenticatedPrincipalCreated)
36from zope.publisher.interfaces import IRequest
[6610]37from zope.password.interfaces import IPasswordManager
[4129]38from zope.securitypolicy.principalrole import principalRoleManager
[7811]39from waeup.kofa.interfaces import (ILocalRoleSetEvent,
[7233]40    IUserAccount, IAuthPluginUtility, IPasswordValidator,
[8973]41    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable,
[10055]42    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
[8973]43from waeup.kofa.utils.batching import BatchProcessor
[12190]44from waeup.kofa.permissions import get_all_roles
[4073]45
46def setup_authentication(pau):
47    """Set up plugguble authentication utility.
48
49    Sets up an IAuthenticatorPlugin and
50    ICredentialsPlugin (for the authentication mechanism)
[5900]51
52    Then looks for any external utilities that want to modify the PAU.
[4073]53    """
[14667]54    pau.credentialsPlugins = (
55            'No Challenge if Authenticated',
56            'xmlrpc-credentials',
57            'credentials')
[6672]58    pau.authenticatorPlugins = ('users',)
[4073]59
[5900]60    # Give any third-party code and subpackages a chance to modify the PAU
61    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
[5901]62    for name, util in auth_plugin_utilities:
[5900]63        util.register(pau)
64
[6673]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
[7819]76class KofaSessionCredentialsPlugin(grok.GlobalUtility,
[4073]77                                    SessionCredentialsPlugin):
[14667]78    """Session plugin that picks usernames/passwords from fields in webforms.
79    """
[4073]80    grok.provides(ICredentialsPlugin)
81    grok.name('credentials')
82
83    loginpagename = 'login'
84    loginfield = 'form.login'
85    passwordfield = 'form.password'
86
[14667]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
[7819]116class KofaPrincipalInfo(object):
117    """An implementation of IKofaPrincipalInfo.
[4073]118
[7819]119    A Kofa principal info is created with id, login, title, description,
[8757]120    phone, email, public_name and user_type.
[7233]121    """
[7819]122    grok.implements(IKofaPrincipalInfo)
[7233]123
[10055]124    def __init__(self, id, title, description, email, phone, public_name,
125                 user_type):
[4073]126        self.id = id
127        self.title = title
128        self.description = description
[7233]129        self.email = email
130        self.phone = phone
[8757]131        self.public_name = public_name
[7240]132        self.user_type = user_type
[4073]133        self.credentialsPlugin = None
134        self.authenticatorPlugin = None
135
[10055]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
[7819]146class KofaPrincipal(Principal):
[7233]147    """A portal principal.
148
[10055]149    Kofa principals provide an extra `email`, `phone`, `public_name`
150    and `user_type` attribute extending ordinary principals.
[7233]151    """
152
[7819]153    grok.implements(IKofaPrincipal)
[7233]154
155    def __init__(self, id, title=u'', description=u'', email=u'',
[8757]156                 phone=None, public_name=u'', user_type=u'', prefix=None):
[7233]157        self.id = id
[7239]158        if prefix is not None:
159            self.id = '%s.%s' % (prefix, self.id)
[7233]160        self.title = title
161        self.description = description
162        self.groups = []
163        self.email = email
164        self.phone = phone
[8757]165        self.public_name = public_name
[7240]166        self.user_type = user_type
[7233]167
168    def __repr__(self):
[7819]169        return 'KofaPrincipal(%r)' % self.id
[7233]170
[7819]171class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
172    """Creates 'authenticated' Kofa principals.
[7233]173
[7819]174    Adapts (principal info, request) to a KofaPrincipal instance.
[7233]175
176    This adapter is used by the standard PAU to transform
[7819]177    KofaPrincipalInfos into KofaPrincipal instances.
[7233]178    """
[7819]179    grok.adapts(IKofaPrincipalInfo, IRequest)
[7233]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):
[7819]187        principal = KofaPrincipal(
[7233]188            self.info.id,
189            self.info.title,
190            self.info.description,
191            self.info.email,
192            self.info.phone,
[8757]193            self.info.public_name,
[7240]194            self.info.user_type,
[7239]195            authentication.prefix,
[7233]196            )
197        notify(
198            AuthenticatedPrincipalCreated(
199                authentication, principal, self.info, self.request))
200        return principal
201
[10055]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
[4073]226class Account(grok.Model):
[10055]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    """
[4109]236    grok.implements(IUserAccount)
[4129]237
[4125]238    def __init__(self, name, password, title=None, description=None,
[8756]239                 email=None, phone=None, public_name=None, roles = []):
[4073]240        self.name = name
[4087]241        if title is None:
242            title = name
243        self.title = title
244        self.description = description
[7221]245        self.email = email
[7233]246        self.phone = phone
[8756]247        self.public_name = public_name
[12926]248        self.suspended = False
[4073]249        self.setPassword(password)
[7636]250        self.setSiteRolesForPrincipal(roles)
[10055]251
[6180]252        # We don't want to share this dict with other accounts
253        self._local_roles = dict()
[10055]254        self.failed_logins = FailedLoginInfo()
[4073]255
256    def setPassword(self, password):
[8343]257        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]258        self.password = passwordmanager.encodePassword(password)
259
260    def checkPassword(self, password):
[13394]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
[8344]267        if not isinstance(password, basestring):
268            return False
269        if not self.password:
270            # unset/empty passwords do never match
271            return False
[12926]272        if self.suspended == True:
273            return False
[8343]274        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]275        return passwordmanager.checkPassword(self.password, password)
276
[7177]277    def getSiteRolesForPrincipal(self):
[6673]278        prm = get_principal_role_manager()
[4129]279        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
280                 if x[0].startswith('waeup.')]
281        return roles
[5055]282
[7177]283    def setSiteRolesForPrincipal(self, roles):
[6673]284        prm = get_principal_role_manager()
[7177]285        old_roles = self.getSiteRolesForPrincipal()
[7658]286        if sorted(old_roles) == sorted(roles):
287            return
[4129]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:
[7658]293            # Convert role to ASCII string to be in line with the
294            # event handler
295            prm.assignRoleToPrincipal(str(role), self.name)
296        return
[4129]297
[7177]298    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
[4129]299
[6180]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]
[6182]312        self._p_changed = True
[6180]313        return
314
[4073]315class UserAuthenticatorPlugin(grok.GlobalUtility):
[6625]316    grok.implements(IAuthenticatorPlugin)
[4073]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
[10055]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
[4073]340        if not account.checkPassword(credentials['password']):
[10055]341            #account.failed_logins.increase()
[4073]342            return None
[7819]343        return KofaPrincipalInfo(
[7315]344            id=account.name,
345            title=account.title,
346            description=account.description,
347            email=account.email,
348            phone=account.phone,
[8757]349            public_name=account.public_name,
[7315]350            user_type=u'user')
[4073]351
352    def principalInfo(self, id):
353        account = self.getAccount(id)
354        if account is None:
355            return None
[7819]356        return KofaPrincipalInfo(
[7315]357            id=account.name,
358            title=account.title,
359            description=account.description,
360            email=account.email,
361            phone=account.phone,
[8757]362            public_name=account.public_name,
[7315]363            user_type=u'user')
[4073]364
365    def getAccount(self, login):
[4087]366        # ... look up the account object and return it ...
[7172]367        userscontainer = self.getUsersContainer()
368        if userscontainer is None:
[4087]369            return
[7172]370        return userscontainer.get(login, None)
[4087]371
372    def addAccount(self, account):
[7172]373        userscontainer = self.getUsersContainer()
374        if userscontainer is None:
[4087]375            return
376        # XXX: complain if name already exists...
[7172]377        userscontainer.addAccount(account)
[4087]378
[4091]379    def addUser(self, name, password, title=None, description=None):
[7172]380        userscontainer = self.getUsersContainer()
381        if userscontainer is None:
[4091]382            return
[7172]383        userscontainer.addUser(name, password, title, description)
[5055]384
[7172]385    def getUsersContainer(self):
[4087]386        site = grok.getSite()
[4743]387        return site['users']
[6203]388
[7147]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
[7169]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
[6203]411@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
[6839]412def handle_account_removed(account, event):
[9313]413    """When an account is removed, local and global roles might
414    have to be deleted.
[6203]415    """
416    local_roles = account.getLocalRoles()
417    principal = account.name
[9313]418
[6203]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:
[9313]424                # No Account object, no role manager, no local roles to remove
[6203]425                continue
426            role_manager.unsetRoleForPrincipal(role_id, principal)
[9313]427    role_manager = IPrincipalRoleManager(grok.getSite())
428    roles = account.getSiteRolesForPrincipal()
429    for role_id in roles:
430        role_manager.unsetRoleForPrincipal(role_id, principal)
[6203]431    return
[7169]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
[7185]436    AcademicsOfficer role must be set.
[7169]437    """
[7173]438    # We set the local Owner role
439    role_manager_account = IPrincipalRoleManager(account)
440    role_manager_account.assignRoleToPrincipal(
[7169]441        'waeup.local.Owner', account.name)
[7185]442    # We set the global AcademicsOfficer role
[7173]443    site = grok.getSite()
444    role_manager_site = IPrincipalRoleManager(site)
445    role_manager_site.assignRoleToPrincipal(
[7185]446        'waeup.AcademicsOfficer', account.name)
[7173]447    # Finally we have to notify the user account that the local role
[7169]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))
[7315]477    return
[8756]478
[8973]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):
[12869]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.
[8973]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
[13182]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
[9706]548    def updateEntry(self, obj, row, site, filename):
[9312]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)
[9706]573        grok.getSite().logger.info('%s - %s - %s - updated: %s'
574            % (self.name, filename, row['name'], items_changed))
[9312]575        return
[8973]576
[12190]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.
[13181]583        roles = row.get('roles', IGNORE_MARKER)
[12190]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
[8756]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]))
[10055]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.')
[8756]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
[10055]628        return
Note: See TracBrowser for help on using the repository browser.