source: main/waeup.ikoba/trunk/src/waeup/ikoba/authentication.py @ 13606

Last change on this file since 13606 was 12189, checked in by Henrik Bettermann, 10 years ago

We need to check if imported roles do exist.

  • Property svn:keywords set to Id
File size: 19.3 KB
RevLine 
[7193]1## $Id: authentication.py 12189 2014-12-10 11:12:42Z 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##
[11949]18"""Authentication for Ikoba.
[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
31from zope.pluggableauth.interfaces import (
[7233]32        ICredentialsPlugin, IAuthenticatorPlugin,
33        IAuthenticatedPrincipalFactory, AuthenticatedPrincipalCreated)
34from zope.publisher.interfaces import IRequest
[6610]35from zope.password.interfaces import IPasswordManager
[4129]36from zope.securitypolicy.principalrole import principalRoleManager
[11949]37from waeup.ikoba.interfaces import (ILocalRoleSetEvent,
[7233]38    IUserAccount, IAuthPluginUtility, IPasswordValidator,
[11949]39    IIkobaPrincipal, IIkobaPrincipalInfo, IIkobaPluggable,
[10055]40    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
[11949]41from waeup.ikoba.utils.batching import BatchProcessor
[12189]42from waeup.ikoba.permissions import get_all_roles
[4073]43
44def setup_authentication(pau):
45    """Set up plugguble authentication utility.
46
47    Sets up an IAuthenticatorPlugin and
48    ICredentialsPlugin (for the authentication mechanism)
[5900]49
50    Then looks for any external utilities that want to modify the PAU.
[4073]51    """
[6661]52    pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials')
[6672]53    pau.authenticatorPlugins = ('users',)
[4073]54
[5900]55    # Give any third-party code and subpackages a chance to modify the PAU
56    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
[5901]57    for name, util in auth_plugin_utilities:
[5900]58        util.register(pau)
59
[6673]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
[11949]71class IkobaSessionCredentialsPlugin(grok.GlobalUtility,
[4073]72                                    SessionCredentialsPlugin):
73    grok.provides(ICredentialsPlugin)
74    grok.name('credentials')
75
76    loginpagename = 'login'
77    loginfield = 'form.login'
78    passwordfield = 'form.password'
79
[11949]80class IkobaPrincipalInfo(object):
81    """An implementation of IIkobaPrincipalInfo.
[4073]82
[11949]83    A Ikoba principal info is created with id, login, title, description,
[8757]84    phone, email, public_name and user_type.
[7233]85    """
[11949]86    grok.implements(IIkobaPrincipalInfo)
[7233]87
[10055]88    def __init__(self, id, title, description, email, phone, public_name,
89                 user_type):
[4073]90        self.id = id
91        self.title = title
92        self.description = description
[7233]93        self.email = email
94        self.phone = phone
[8757]95        self.public_name = public_name
[7240]96        self.user_type = user_type
[4073]97        self.credentialsPlugin = None
98        self.authenticatorPlugin = None
99
[10055]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
[11949]110class IkobaPrincipal(Principal):
[7233]111    """A portal principal.
112
[11949]113    Ikoba principals provide an extra `email`, `phone`, `public_name`
[10055]114    and `user_type` attribute extending ordinary principals.
[7233]115    """
116
[11949]117    grok.implements(IIkobaPrincipal)
[7233]118
119    def __init__(self, id, title=u'', description=u'', email=u'',
[8757]120                 phone=None, public_name=u'', user_type=u'', prefix=None):
[7233]121        self.id = id
[7239]122        if prefix is not None:
123            self.id = '%s.%s' % (prefix, self.id)
[7233]124        self.title = title
125        self.description = description
126        self.groups = []
127        self.email = email
128        self.phone = phone
[8757]129        self.public_name = public_name
[7240]130        self.user_type = user_type
[7233]131
132    def __repr__(self):
[11949]133        return 'IkobaPrincipal(%r)' % self.id
[7233]134
[11949]135class AuthenticatedIkobaPrincipalFactory(grok.MultiAdapter):
136    """Creates 'authenticated' Ikoba principals.
[7233]137
[11949]138    Adapts (principal info, request) to a IkobaPrincipal instance.
[7233]139
140    This adapter is used by the standard PAU to transform
[11949]141    IkobaPrincipalInfos into IkobaPrincipal instances.
[7233]142    """
[11949]143    grok.adapts(IIkobaPrincipalInfo, IRequest)
[7233]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):
[11949]151        principal = IkobaPrincipal(
[7233]152            self.info.id,
153            self.info.title,
154            self.info.description,
155            self.info.email,
156            self.info.phone,
[8757]157            self.info.public_name,
[7240]158            self.info.user_type,
[7239]159            authentication.prefix,
[7233]160            )
161        notify(
162            AuthenticatedPrincipalCreated(
163                authentication, principal, self.info, self.request))
164        return principal
165
[10055]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
[4073]190class Account(grok.Model):
[11949]191    """Ikoba user accounts store infos about a user.
[10055]192
193    Beside the usual data and an (encrypted) password, accounts also
194    have a persistent attribute `failed_logins` which is an instance
[11949]195    of `waeup.ikoba.authentication.FailedLoginInfo`.
[10055]196
197    This attribute can be manipulated directly (set new value,
198    increase values, or reset).
199    """
[4109]200    grok.implements(IUserAccount)
[4129]201
[4125]202    def __init__(self, name, password, title=None, description=None,
[8756]203                 email=None, phone=None, public_name=None, roles = []):
[4073]204        self.name = name
[4087]205        if title is None:
206            title = name
207        self.title = title
208        self.description = description
[7221]209        self.email = email
[7233]210        self.phone = phone
[8756]211        self.public_name = public_name
[4073]212        self.setPassword(password)
[7636]213        self.setSiteRolesForPrincipal(roles)
[10055]214
[6180]215        # We don't want to share this dict with other accounts
216        self._local_roles = dict()
[10055]217        self.failed_logins = FailedLoginInfo()
[4073]218
219    def setPassword(self, password):
[8343]220        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]221        self.password = passwordmanager.encodePassword(password)
222
223    def checkPassword(self, password):
[8344]224        if not isinstance(password, basestring):
225            return False
226        if not self.password:
227            # unset/empty passwords do never match
228            return False
[8343]229        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]230        return passwordmanager.checkPassword(self.password, password)
231
[7177]232    def getSiteRolesForPrincipal(self):
[6673]233        prm = get_principal_role_manager()
[4129]234        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
235                 if x[0].startswith('waeup.')]
236        return roles
[5055]237
[7177]238    def setSiteRolesForPrincipal(self, roles):
[6673]239        prm = get_principal_role_manager()
[7177]240        old_roles = self.getSiteRolesForPrincipal()
[7658]241        if sorted(old_roles) == sorted(roles):
242            return
[4129]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:
[7658]248            # Convert role to ASCII string to be in line with the
249            # event handler
250            prm.assignRoleToPrincipal(str(role), self.name)
251        return
[4129]252
[7177]253    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
[4129]254
[6180]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]
[6182]267        self._p_changed = True
[6180]268        return
269
[4073]270class UserAuthenticatorPlugin(grok.GlobalUtility):
[6625]271    grok.implements(IAuthenticatorPlugin)
[4073]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
[10055]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
[4073]295        if not account.checkPassword(credentials['password']):
[10055]296            #account.failed_logins.increase()
[4073]297            return None
[11949]298        return IkobaPrincipalInfo(
[7315]299            id=account.name,
300            title=account.title,
301            description=account.description,
302            email=account.email,
303            phone=account.phone,
[8757]304            public_name=account.public_name,
[7315]305            user_type=u'user')
[4073]306
307    def principalInfo(self, id):
308        account = self.getAccount(id)
309        if account is None:
310            return None
[11949]311        return IkobaPrincipalInfo(
[7315]312            id=account.name,
313            title=account.title,
314            description=account.description,
315            email=account.email,
316            phone=account.phone,
[8757]317            public_name=account.public_name,
[7315]318            user_type=u'user')
[4073]319
320    def getAccount(self, login):
[4087]321        # ... look up the account object and return it ...
[7172]322        userscontainer = self.getUsersContainer()
323        if userscontainer is None:
[4087]324            return
[7172]325        return userscontainer.get(login, None)
[4087]326
327    def addAccount(self, account):
[7172]328        userscontainer = self.getUsersContainer()
329        if userscontainer is None:
[4087]330            return
331        # XXX: complain if name already exists...
[7172]332        userscontainer.addAccount(account)
[4087]333
[4091]334    def addUser(self, name, password, title=None, description=None):
[7172]335        userscontainer = self.getUsersContainer()
336        if userscontainer is None:
[4091]337            return
[7172]338        userscontainer.addUser(name, password, title, description)
[5055]339
[7172]340    def getUsersContainer(self):
[4087]341        site = grok.getSite()
[4743]342        return site['users']
[6203]343
[7147]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
[7169]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
[6203]366@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
[6839]367def handle_account_removed(account, event):
[9313]368    """When an account is removed, local and global roles might
369    have to be deleted.
[6203]370    """
371    local_roles = account.getLocalRoles()
372    principal = account.name
[9313]373
[6203]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:
[9313]379                # No Account object, no role manager, no local roles to remove
[6203]380                continue
381            role_manager.unsetRoleForPrincipal(role_id, principal)
[9313]382    role_manager = IPrincipalRoleManager(grok.getSite())
383    roles = account.getSiteRolesForPrincipal()
384    for role_id in roles:
385        role_manager.unsetRoleForPrincipal(role_id, principal)
[6203]386    return
[7169]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
[11947]391    ProductsOfficer role must be set.
[7169]392    """
[7173]393    # We set the local Owner role
394    role_manager_account = IPrincipalRoleManager(account)
395    role_manager_account.assignRoleToPrincipal(
[7169]396        'waeup.local.Owner', account.name)
[11947]397    # We set the global ProductsOfficer role
[7173]398    site = grok.getSite()
399    role_manager_site = IPrincipalRoleManager(site)
400    role_manager_site.assignRoleToPrincipal(
[11947]401        'waeup.ProductsOfficer', account.name)
[7173]402    # Finally we have to notify the user account that the local role
[7169]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))
[7315]432    return
[8756]433
[8973]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    """A batch processor for IUserAccount objects.
452    """
453    grok.implements(IBatchProcessor)
454    grok.provides(IBatchProcessor)
455    grok.context(Interface)
456    util_name = 'userprocessor'
457    grok.name(util_name)
458
459    name = u'User Processor'
460    iface = IUserAccount
461
462    location_fields = ['name',]
463    factory_name = 'waeup.UserAccount'
464
465    mode = None
466
467    def parentsExist(self, row, site):
468        return 'users' in site.keys()
469
470    def entryExists(self, row, site):
471        return row['name'] in site['users'].keys()
472
473    def getParent(self, row, site):
474        return site['users']
475
476    def getEntry(self, row, site):
477        if not self.entryExists(row, site):
478            return None
479        parent = self.getParent(row, site)
480        return parent.get(row['name'])
481
482    def addEntry(self, obj, row, site):
483        parent = self.getParent(row, site)
484        parent.addAccount(obj)
485        return
486
[9706]487    def updateEntry(self, obj, row, site, filename):
[9312]488        """Update obj to the values given in row.
489        """
490        changed = []
491        for key, value in row.items():
492            if  key == 'roles':
493                # We cannot simply set the roles attribute here because
494                # we can't assure that the name attribute is set before
495                # the roles attribute is set.
496                continue
497            # Skip fields to be ignored.
498            if value == IGNORE_MARKER:
499                continue
500            if not hasattr(obj, key):
501                continue
502            setattr(obj, key, value)
503            changed.append('%s=%s' % (key, value))
504        roles = row.get('roles', IGNORE_MARKER)
505        if roles not in ('', IGNORE_MARKER):
506            evalvalue = eval(roles)
507            if isinstance(evalvalue, list):
508                setattr(obj, 'roles', evalvalue)
509                changed.append('roles=%s' % roles)
510        # Log actions...
511        items_changed = ', '.join(changed)
[9706]512        grok.getSite().logger.info('%s - %s - %s - updated: %s'
513            % (self.name, filename, row['name'], items_changed))
[9312]514        return
[8973]515
[12189]516    def checkConversion(self, row, mode='ignore'):
517        """Validates all values in row.
518        """
519        errs, inv_errs, conv_dict = super(
520            UserProcessor, self).checkConversion(row, mode=mode)
521        # We need to check if roles exist.
522        roles = row.get('roles', None)
523        all_roles = [i[0] for i in get_all_roles()]
524        if roles not in ('', IGNORE_MARKER):
525            evalvalue = eval(roles)
526            for role in evalvalue:
527                if role not in all_roles:
528                    errs.append(('roles','invalid role'))
529        return errs, inv_errs, conv_dict
530
[8756]531class UsersPlugin(grok.GlobalUtility):
532    """A plugin that updates users.
533    """
[11949]534    grok.implements(IIkobaPluggable)
[8756]535    grok.name('users')
536
537    deprecated_attributes = []
538
539    def setup(self, site, name, logger):
540        return
541
542    def update(self, site, name, logger):
543        users = site['users']
544        items = getFields(IUserAccount).items()
545        for user in users.values():
546            # Add new attributes
547            for i in items:
548                if not hasattr(user,i[0]):
549                    setattr(user,i[0],i[1].missing_value)
550                    logger.info(
551                        'UsersPlugin: %s attribute %s added.' % (
552                        user.name,i[0]))
[10055]553            if not hasattr(user, 'failed_logins'):
554                # add attribute `failed_logins`...
555                user.failed_logins = FailedLoginInfo()
556                logger.info(
557                    'UsersPlugin: attribute failed_logins added.')
[8756]558            # Remove deprecated attributes
559            for i in self.deprecated_attributes:
560                try:
561                    delattr(user,i)
562                    logger.info(
563                        'UsersPlugin: %s attribute %s deleted.' % (
564                        user.name,i))
565                except AttributeError:
566                    pass
[10055]567        return
Note: See TracBrowser for help on using the repository browser.