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

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

See r13181 and r13182.

  • Property svn:keywords set to Id
File size: 19.6 KB
Line 
1## $Id: authentication.py 13801 2016-04-05 20:00:44Z henrik $
2##
3## Copyright (C) 2011 Uli Fouquet & Henrik Bettermann
4## This program is free software; you can redistribute it and/or modify
5## it under the terms of the GNU General Public License as published by
6## the Free Software Foundation; either version 2 of the License, or
7## (at your option) any later version.
8##
9## This program is distributed in the hope that it will be useful,
10## but WITHOUT ANY WARRANTY; without even the implied warranty of
11## MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12## GNU General Public License for more details.
13##
14## You should have received a copy of the GNU General Public License
15## along with this program; if not, write to the Free Software
16## Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
17##
18"""Authentication for Ikoba.
19"""
20import grok
21import time
22from zope.event import notify
23from zope.component import getUtility, getUtilitiesFor
24from zope.component.interfaces import IFactory
25from zope.interface import Interface, implementedBy
26from zope.schema import getFields
27from zope.securitypolicy.interfaces import (
28    IPrincipalRoleMap, IPrincipalRoleManager)
29from zope.pluggableauth.factories import Principal
30from zope.pluggableauth.plugins.session import SessionCredentialsPlugin
31from zope.pluggableauth.interfaces import (
32        ICredentialsPlugin, IAuthenticatorPlugin,
33        IAuthenticatedPrincipalFactory, AuthenticatedPrincipalCreated)
34from zope.publisher.interfaces import IRequest
35from zope.password.interfaces import IPasswordManager
36from zope.securitypolicy.principalrole import principalRoleManager
37from waeup.ikoba.interfaces import (ILocalRoleSetEvent,
38    IUserAccount, IAuthPluginUtility, IPasswordValidator,
39    IIkobaPrincipal, IIkobaPrincipalInfo, IIkobaPluggable,
40    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
41from waeup.ikoba.utils.batching import BatchProcessor
42from waeup.ikoba.permissions import get_all_roles
43
44def setup_authentication(pau):
45    """Set up plugguble authentication utility.
46
47    Sets up an IAuthenticatorPlugin and
48    ICredentialsPlugin (for the authentication mechanism)
49
50    Then looks for any external utilities that want to modify the PAU.
51    """
52    pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials')
53    pau.authenticatorPlugins = ('users',)
54
55    # Give any third-party code and subpackages a chance to modify the PAU
56    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
57    for name, util in auth_plugin_utilities:
58        util.register(pau)
59
60def get_principal_role_manager():
61    """Get a role manager for principals.
62
63    If we are currently 'in a site', return the role manager for the
64    portal or the global rolemanager else.
65    """
66    portal = grok.getSite()
67    if portal is not None:
68        return IPrincipalRoleManager(portal)
69    return principalRoleManager
70
71class IkobaSessionCredentialsPlugin(grok.GlobalUtility,
72                                    SessionCredentialsPlugin):
73    grok.provides(ICredentialsPlugin)
74    grok.name('credentials')
75
76    loginpagename = 'login'
77    loginfield = 'form.login'
78    passwordfield = 'form.password'
79
80class IkobaPrincipalInfo(object):
81    """An implementation of IIkobaPrincipalInfo.
82
83    A Ikoba principal info is created with id, login, title, description,
84    phone, email, public_name and user_type.
85    """
86    grok.implements(IIkobaPrincipalInfo)
87
88    def __init__(self, id, title, description, email, phone, public_name,
89                 user_type):
90        self.id = id
91        self.title = title
92        self.description = description
93        self.email = email
94        self.phone = phone
95        self.public_name = public_name
96        self.user_type = user_type
97        self.credentialsPlugin = None
98        self.authenticatorPlugin = None
99
100    def __eq__(self, obj):
101        default = object()
102        result = []
103        for name in ('id', 'title', 'description', 'email', 'phone',
104                     'public_name', 'user_type', 'credentialsPlugin',
105                     'authenticatorPlugin'):
106            result.append(
107                getattr(self, name) == getattr(obj, name, default))
108        return False not in result
109
110class IkobaPrincipal(Principal):
111    """A portal principal.
112
113    Ikoba principals provide an extra `email`, `phone`, `public_name`
114    and `user_type` attribute extending ordinary principals.
115    """
116
117    grok.implements(IIkobaPrincipal)
118
119    def __init__(self, id, title=u'', description=u'', email=u'',
120                 phone=None, public_name=u'', user_type=u'', prefix=None):
121        self.id = id
122        if prefix is not None:
123            self.id = '%s.%s' % (prefix, self.id)
124        self.title = title
125        self.description = description
126        self.groups = []
127        self.email = email
128        self.phone = phone
129        self.public_name = public_name
130        self.user_type = user_type
131
132    def __repr__(self):
133        return 'IkobaPrincipal(%r)' % self.id
134
135class AuthenticatedIkobaPrincipalFactory(grok.MultiAdapter):
136    """Creates 'authenticated' Ikoba principals.
137
138    Adapts (principal info, request) to a IkobaPrincipal instance.
139
140    This adapter is used by the standard PAU to transform
141    IkobaPrincipalInfos into IkobaPrincipal instances.
142    """
143    grok.adapts(IIkobaPrincipalInfo, IRequest)
144    grok.implements(IAuthenticatedPrincipalFactory)
145
146    def __init__(self, info, request):
147        self.info = info
148        self.request = request
149
150    def __call__(self, authentication):
151        principal = IkobaPrincipal(
152            self.info.id,
153            self.info.title,
154            self.info.description,
155            self.info.email,
156            self.info.phone,
157            self.info.public_name,
158            self.info.user_type,
159            authentication.prefix,
160            )
161        notify(
162            AuthenticatedPrincipalCreated(
163                authentication, principal, self.info, self.request))
164        return principal
165
166class FailedLoginInfo(grok.Model):
167    grok.implements(IFailedLoginInfo)
168
169    def __init__(self, num=0, last=None):
170        self.num = num
171        self.last = last
172        return
173
174    def as_tuple(self):
175        return (self.num, self.last)
176
177    def set_values(self, num=0, last=None):
178        self.num, self.last = num, last
179        self._p_changed = True
180        pass
181
182    def increase(self):
183        self.set_values(num=self.num + 1, last=time.time())
184        pass
185
186    def reset(self):
187        self.set_values(num=0, last=None)
188        pass
189
190class Account(grok.Model):
191    """Ikoba user accounts store infos about a user.
192
193    Beside the usual data and an (encrypted) password, accounts also
194    have a persistent attribute `failed_logins` which is an instance
195    of `waeup.ikoba.authentication.FailedLoginInfo`.
196
197    This attribute can be manipulated directly (set new value,
198    increase values, or reset).
199    """
200    grok.implements(IUserAccount)
201
202    def __init__(self, name, password, title=None, description=None,
203                 email=None, phone=None, public_name=None, roles = []):
204        self.name = name
205        if title is None:
206            title = name
207        self.title = title
208        self.description = description
209        self.email = email
210        self.phone = phone
211        self.public_name = public_name
212        self.setPassword(password)
213        self.setSiteRolesForPrincipal(roles)
214
215        # We don't want to share this dict with other accounts
216        self._local_roles = dict()
217        self.failed_logins = FailedLoginInfo()
218
219    def setPassword(self, password):
220        passwordmanager = getUtility(IPasswordManager, 'SSHA')
221        self.password = passwordmanager.encodePassword(password)
222
223    def checkPassword(self, password):
224        if not isinstance(password, basestring):
225            return False
226        if not self.password:
227            # unset/empty passwords do never match
228            return False
229        passwordmanager = getUtility(IPasswordManager, 'SSHA')
230        return passwordmanager.checkPassword(self.password, password)
231
232    def getSiteRolesForPrincipal(self):
233        prm = get_principal_role_manager()
234        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
235                 if x[0].startswith('waeup.')]
236        return roles
237
238    def setSiteRolesForPrincipal(self, roles):
239        prm = get_principal_role_manager()
240        old_roles = self.getSiteRolesForPrincipal()
241        if sorted(old_roles) == sorted(roles):
242            return
243        for role in old_roles:
244            # Remove old roles, not to be set now...
245            if role.startswith('waeup.') and role not in roles:
246                prm.unsetRoleForPrincipal(role, self.name)
247        for role in roles:
248            # Convert role to ASCII string to be in line with the
249            # event handler
250            prm.assignRoleToPrincipal(str(role), self.name)
251        return
252
253    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
254
255    def getLocalRoles(self):
256        return self._local_roles
257
258    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
259        objects = self._local_roles.get(role_id, [])
260        if granted and obj not in objects:
261            objects.append(obj)
262        if not granted and obj in objects:
263            objects.remove(obj)
264        self._local_roles[role_id] = objects
265        if len(objects) == 0:
266            del self._local_roles[role_id]
267        self._p_changed = True
268        return
269
270class UserAuthenticatorPlugin(grok.GlobalUtility):
271    grok.implements(IAuthenticatorPlugin)
272    grok.provides(IAuthenticatorPlugin)
273    grok.name('users')
274
275    def authenticateCredentials(self, credentials):
276        if not isinstance(credentials, dict):
277            return None
278        if not ('login' in credentials and 'password' in credentials):
279            return None
280        account = self.getAccount(credentials['login'])
281        if account is None:
282            return None
283        # The following shows how 'time penalties' could be enforced
284        # on failed logins. First three failed logins are 'for
285        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
286        # 32, ... seconds before a login can succeed.
287        # There are, however, some problems to discuss, before we
288        # really use this in all authenticators.
289
290        #num, last = account.failed_logins.as_tuple()
291        #if (num > 2) and (time.time() < (last + 2**(num-3))):
292        #    # tried login while account still blocked due to previous
293        #    # login errors.
294        #    return None
295        if not account.checkPassword(credentials['password']):
296            #account.failed_logins.increase()
297            return None
298        return IkobaPrincipalInfo(
299            id=account.name,
300            title=account.title,
301            description=account.description,
302            email=account.email,
303            phone=account.phone,
304            public_name=account.public_name,
305            user_type=u'user')
306
307    def principalInfo(self, id):
308        account = self.getAccount(id)
309        if account is None:
310            return None
311        return IkobaPrincipalInfo(
312            id=account.name,
313            title=account.title,
314            description=account.description,
315            email=account.email,
316            phone=account.phone,
317            public_name=account.public_name,
318            user_type=u'user')
319
320    def getAccount(self, login):
321        # ... look up the account object and return it ...
322        userscontainer = self.getUsersContainer()
323        if userscontainer is None:
324            return
325        return userscontainer.get(login, None)
326
327    def addAccount(self, account):
328        userscontainer = self.getUsersContainer()
329        if userscontainer is None:
330            return
331        # XXX: complain if name already exists...
332        userscontainer.addAccount(account)
333
334    def addUser(self, name, password, title=None, description=None):
335        userscontainer = self.getUsersContainer()
336        if userscontainer is None:
337            return
338        userscontainer.addUser(name, password, title, description)
339
340    def getUsersContainer(self):
341        site = grok.getSite()
342        return site['users']
343
344class PasswordValidator(grok.GlobalUtility):
345
346  grok.implements(IPasswordValidator)
347
348  def validate_password(self, pw, pw_repeat):
349       errors = []
350       if len(pw) < 3:
351         errors.append('Password must have at least 3 chars.')
352       if pw != pw_repeat:
353         errors.append('Passwords do not match.')
354       return errors
355
356class LocalRoleSetEvent(object):
357
358    grok.implements(ILocalRoleSetEvent)
359
360    def __init__(self, object, role_id, principal_id, granted=True):
361        self.object = object
362        self.role_id = role_id
363        self.principal_id = principal_id
364        self.granted = granted
365
366@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
367def handle_account_removed(account, event):
368    """When an account is removed, local and global roles might
369    have to be deleted.
370    """
371    local_roles = account.getLocalRoles()
372    principal = account.name
373
374    for role_id, object_list in local_roles.items():
375        for object in object_list:
376            try:
377                role_manager = IPrincipalRoleManager(object)
378            except TypeError:
379                # No Account object, no role manager, no local roles to remove
380                continue
381            role_manager.unsetRoleForPrincipal(role_id, principal)
382    role_manager = IPrincipalRoleManager(grok.getSite())
383    roles = account.getSiteRolesForPrincipal()
384    for role_id in roles:
385        role_manager.unsetRoleForPrincipal(role_id, principal)
386    return
387
388@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
389def handle_account_added(account, event):
390    """When an account is added, the local owner role and the global
391    ProductsOfficer role must be set.
392    """
393    # We set the local Owner role
394    role_manager_account = IPrincipalRoleManager(account)
395    role_manager_account.assignRoleToPrincipal(
396        'waeup.local.Owner', account.name)
397    # We set the global ProductsOfficer role
398    site = grok.getSite()
399    role_manager_site = IPrincipalRoleManager(site)
400    role_manager_site.assignRoleToPrincipal(
401        'waeup.ProductsOfficer', account.name)
402    # Finally we have to notify the user account that the local role
403    # of the same object has changed
404    notify(LocalRoleSetEvent(
405        account, 'waeup.local.Owner', account.name, granted=True))
406    return
407
408@grok.subscribe(Interface, ILocalRoleSetEvent)
409def handle_local_role_changed(obj, event):
410    site = grok.getSite()
411    if site is None:
412        return
413    users = site.get('users', None)
414    if users is None:
415        return
416    if event.principal_id not in users.keys():
417        return
418    user = users[event.principal_id]
419    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
420    return
421
422@grok.subscribe(Interface, grok.IObjectRemovedEvent)
423def handle_local_roles_on_obj_removed(obj, event):
424    try:
425        role_map = IPrincipalRoleMap(obj)
426    except TypeError:
427        # no map, no roles to remove
428        return
429    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
430        notify(LocalRoleSetEvent(
431                obj, local_role, user_name, granted=False))
432    return
433
434class UserAccountFactory(grok.GlobalUtility):
435    """A factory for user accounts.
436
437    This factory is only needed for imports.
438    """
439    grok.implements(IFactory)
440    grok.name(u'waeup.UserAccount')
441    title = u"Create a user.",
442    description = u"This factory instantiates new user account instances."
443
444    def __call__(self, *args, **kw):
445        return Account(name=None, password='')
446
447    def getInterfaces(self):
448        return implementedBy(Account)
449
450class UserProcessor(BatchProcessor):
451    """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
487    def delEntry(self, row, site):
488        user = self.getEntry(row, site)
489        if user is not None:
490            parent = self.getParent(row, site)
491            grok.getSite().logger.info('%s - %s - User removed'
492                % (self.name, row['name']))
493            del parent[user.name]
494        pass
495
496    def updateEntry(self, obj, row, site, filename):
497        """Update obj to the values given in row.
498        """
499        changed = []
500        for key, value in row.items():
501            if  key == 'roles':
502                # We cannot simply set the roles attribute here because
503                # we can't assure that the name attribute is set before
504                # the roles attribute is set.
505                continue
506            # Skip fields to be ignored.
507            if value == IGNORE_MARKER:
508                continue
509            if not hasattr(obj, key):
510                continue
511            setattr(obj, key, value)
512            changed.append('%s=%s' % (key, value))
513        roles = row.get('roles', IGNORE_MARKER)
514        if roles not in ('', IGNORE_MARKER):
515            evalvalue = eval(roles)
516            if isinstance(evalvalue, list):
517                setattr(obj, 'roles', evalvalue)
518                changed.append('roles=%s' % roles)
519        # Log actions...
520        items_changed = ', '.join(changed)
521        grok.getSite().logger.info('%s - %s - %s - updated: %s'
522            % (self.name, filename, row['name'], items_changed))
523        return
524
525    def checkConversion(self, row, mode='ignore'):
526        """Validates all values in row.
527        """
528        errs, inv_errs, conv_dict = super(
529            UserProcessor, self).checkConversion(row, mode=mode)
530        # We need to check if roles exist.
531        roles = row.get('roles', IGNORE_MARKER)
532        all_roles = [i[0] for i in get_all_roles()]
533        if roles not in ('', IGNORE_MARKER):
534            evalvalue = eval(roles)
535            for role in evalvalue:
536                if role not in all_roles:
537                    errs.append(('roles','invalid role'))
538        return errs, inv_errs, conv_dict
539
540class UsersPlugin(grok.GlobalUtility):
541    """A plugin that updates users.
542    """
543    grok.implements(IIkobaPluggable)
544    grok.name('users')
545
546    deprecated_attributes = []
547
548    def setup(self, site, name, logger):
549        return
550
551    def update(self, site, name, logger):
552        users = site['users']
553        items = getFields(IUserAccount).items()
554        for user in users.values():
555            # Add new attributes
556            for i in items:
557                if not hasattr(user,i[0]):
558                    setattr(user,i[0],i[1].missing_value)
559                    logger.info(
560                        'UsersPlugin: %s attribute %s added.' % (
561                        user.name,i[0]))
562            if not hasattr(user, 'failed_logins'):
563                # add attribute `failed_logins`...
564                user.failed_logins = FailedLoginInfo()
565                logger.info(
566                    'UsersPlugin: attribute failed_logins added.')
567            # Remove deprecated attributes
568            for i in self.deprecated_attributes:
569                try:
570                    delattr(user,i)
571                    logger.info(
572                        'UsersPlugin: %s attribute %s deleted.' % (
573                        user.name,i))
574                except AttributeError:
575                    pass
576        return
Note: See TracBrowser for help on using the repository browser.