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

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

Enable temporary suspension of officer accounts. Plugins must be
updated after restart.

See r12926.

  • Property svn:keywords set to Id
File size: 19.7 KB
Line 
1## $Id: authentication.py 13803 2016-04-06 05:04:26Z 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.suspended = False
213        self.setPassword(password)
214        self.setSiteRolesForPrincipal(roles)
215
216        # We don't want to share this dict with other accounts
217        self._local_roles = dict()
218        self.failed_logins = FailedLoginInfo()
219
220    def setPassword(self, password):
221        passwordmanager = getUtility(IPasswordManager, 'SSHA')
222        self.password = passwordmanager.encodePassword(password)
223
224    def checkPassword(self, password):
225        if not isinstance(password, basestring):
226            return False
227        if not self.password:
228            # unset/empty passwords do never match
229            return False
230        if self.suspended == True:
231            return False
232        passwordmanager = getUtility(IPasswordManager, 'SSHA')
233        return passwordmanager.checkPassword(self.password, password)
234
235    def getSiteRolesForPrincipal(self):
236        prm = get_principal_role_manager()
237        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
238                 if x[0].startswith('waeup.')]
239        return roles
240
241    def setSiteRolesForPrincipal(self, roles):
242        prm = get_principal_role_manager()
243        old_roles = self.getSiteRolesForPrincipal()
244        if sorted(old_roles) == sorted(roles):
245            return
246        for role in old_roles:
247            # Remove old roles, not to be set now...
248            if role.startswith('waeup.') and role not in roles:
249                prm.unsetRoleForPrincipal(role, self.name)
250        for role in roles:
251            # Convert role to ASCII string to be in line with the
252            # event handler
253            prm.assignRoleToPrincipal(str(role), self.name)
254        return
255
256    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
257
258    def getLocalRoles(self):
259        return self._local_roles
260
261    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
262        objects = self._local_roles.get(role_id, [])
263        if granted and obj not in objects:
264            objects.append(obj)
265        if not granted and obj in objects:
266            objects.remove(obj)
267        self._local_roles[role_id] = objects
268        if len(objects) == 0:
269            del self._local_roles[role_id]
270        self._p_changed = True
271        return
272
273class UserAuthenticatorPlugin(grok.GlobalUtility):
274    grok.implements(IAuthenticatorPlugin)
275    grok.provides(IAuthenticatorPlugin)
276    grok.name('users')
277
278    def authenticateCredentials(self, credentials):
279        if not isinstance(credentials, dict):
280            return None
281        if not ('login' in credentials and 'password' in credentials):
282            return None
283        account = self.getAccount(credentials['login'])
284        if account is None:
285            return None
286        # The following shows how 'time penalties' could be enforced
287        # on failed logins. First three failed logins are 'for
288        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
289        # 32, ... seconds before a login can succeed.
290        # There are, however, some problems to discuss, before we
291        # really use this in all authenticators.
292
293        #num, last = account.failed_logins.as_tuple()
294        #if (num > 2) and (time.time() < (last + 2**(num-3))):
295        #    # tried login while account still blocked due to previous
296        #    # login errors.
297        #    return None
298        if not account.checkPassword(credentials['password']):
299            #account.failed_logins.increase()
300            return None
301        return IkobaPrincipalInfo(
302            id=account.name,
303            title=account.title,
304            description=account.description,
305            email=account.email,
306            phone=account.phone,
307            public_name=account.public_name,
308            user_type=u'user')
309
310    def principalInfo(self, id):
311        account = self.getAccount(id)
312        if account is None:
313            return None
314        return IkobaPrincipalInfo(
315            id=account.name,
316            title=account.title,
317            description=account.description,
318            email=account.email,
319            phone=account.phone,
320            public_name=account.public_name,
321            user_type=u'user')
322
323    def getAccount(self, login):
324        # ... look up the account object and return it ...
325        userscontainer = self.getUsersContainer()
326        if userscontainer is None:
327            return
328        return userscontainer.get(login, None)
329
330    def addAccount(self, account):
331        userscontainer = self.getUsersContainer()
332        if userscontainer is None:
333            return
334        # XXX: complain if name already exists...
335        userscontainer.addAccount(account)
336
337    def addUser(self, name, password, title=None, description=None):
338        userscontainer = self.getUsersContainer()
339        if userscontainer is None:
340            return
341        userscontainer.addUser(name, password, title, description)
342
343    def getUsersContainer(self):
344        site = grok.getSite()
345        return site['users']
346
347class PasswordValidator(grok.GlobalUtility):
348
349  grok.implements(IPasswordValidator)
350
351  def validate_password(self, pw, pw_repeat):
352       errors = []
353       if len(pw) < 3:
354         errors.append('Password must have at least 3 chars.')
355       if pw != pw_repeat:
356         errors.append('Passwords do not match.')
357       return errors
358
359class LocalRoleSetEvent(object):
360
361    grok.implements(ILocalRoleSetEvent)
362
363    def __init__(self, object, role_id, principal_id, granted=True):
364        self.object = object
365        self.role_id = role_id
366        self.principal_id = principal_id
367        self.granted = granted
368
369@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
370def handle_account_removed(account, event):
371    """When an account is removed, local and global roles might
372    have to be deleted.
373    """
374    local_roles = account.getLocalRoles()
375    principal = account.name
376
377    for role_id, object_list in local_roles.items():
378        for object in object_list:
379            try:
380                role_manager = IPrincipalRoleManager(object)
381            except TypeError:
382                # No Account object, no role manager, no local roles to remove
383                continue
384            role_manager.unsetRoleForPrincipal(role_id, principal)
385    role_manager = IPrincipalRoleManager(grok.getSite())
386    roles = account.getSiteRolesForPrincipal()
387    for role_id in roles:
388        role_manager.unsetRoleForPrincipal(role_id, principal)
389    return
390
391@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
392def handle_account_added(account, event):
393    """When an account is added, the local owner role and the global
394    ProductsOfficer role must be set.
395    """
396    # We set the local Owner role
397    role_manager_account = IPrincipalRoleManager(account)
398    role_manager_account.assignRoleToPrincipal(
399        'waeup.local.Owner', account.name)
400    # We set the global ProductsOfficer role
401    site = grok.getSite()
402    role_manager_site = IPrincipalRoleManager(site)
403    role_manager_site.assignRoleToPrincipal(
404        'waeup.ProductsOfficer', account.name)
405    # Finally we have to notify the user account that the local role
406    # of the same object has changed
407    notify(LocalRoleSetEvent(
408        account, 'waeup.local.Owner', account.name, granted=True))
409    return
410
411@grok.subscribe(Interface, ILocalRoleSetEvent)
412def handle_local_role_changed(obj, event):
413    site = grok.getSite()
414    if site is None:
415        return
416    users = site.get('users', None)
417    if users is None:
418        return
419    if event.principal_id not in users.keys():
420        return
421    user = users[event.principal_id]
422    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
423    return
424
425@grok.subscribe(Interface, grok.IObjectRemovedEvent)
426def handle_local_roles_on_obj_removed(obj, event):
427    try:
428        role_map = IPrincipalRoleMap(obj)
429    except TypeError:
430        # no map, no roles to remove
431        return
432    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
433        notify(LocalRoleSetEvent(
434                obj, local_role, user_name, granted=False))
435    return
436
437class UserAccountFactory(grok.GlobalUtility):
438    """A factory for user accounts.
439
440    This factory is only needed for imports.
441    """
442    grok.implements(IFactory)
443    grok.name(u'waeup.UserAccount')
444    title = u"Create a user.",
445    description = u"This factory instantiates new user account instances."
446
447    def __call__(self, *args, **kw):
448        return Account(name=None, password='')
449
450    def getInterfaces(self):
451        return implementedBy(Account)
452
453class UserProcessor(BatchProcessor):
454    """A batch processor for IUserAccount objects.
455    """
456    grok.implements(IBatchProcessor)
457    grok.provides(IBatchProcessor)
458    grok.context(Interface)
459    util_name = 'userprocessor'
460    grok.name(util_name)
461
462    name = u'User Processor'
463    iface = IUserAccount
464
465    location_fields = ['name',]
466    factory_name = 'waeup.UserAccount'
467
468    mode = None
469
470    def parentsExist(self, row, site):
471        return 'users' in site.keys()
472
473    def entryExists(self, row, site):
474        return row['name'] in site['users'].keys()
475
476    def getParent(self, row, site):
477        return site['users']
478
479    def getEntry(self, row, site):
480        if not self.entryExists(row, site):
481            return None
482        parent = self.getParent(row, site)
483        return parent.get(row['name'])
484
485    def addEntry(self, obj, row, site):
486        parent = self.getParent(row, site)
487        parent.addAccount(obj)
488        return
489
490    def delEntry(self, row, site):
491        user = self.getEntry(row, site)
492        if user is not None:
493            parent = self.getParent(row, site)
494            grok.getSite().logger.info('%s - %s - User removed'
495                % (self.name, row['name']))
496            del parent[user.name]
497        pass
498
499    def updateEntry(self, obj, row, site, filename):
500        """Update obj to the values given in row.
501        """
502        changed = []
503        for key, value in row.items():
504            if  key == 'roles':
505                # We cannot simply set the roles attribute here because
506                # we can't assure that the name attribute is set before
507                # the roles attribute is set.
508                continue
509            # Skip fields to be ignored.
510            if value == IGNORE_MARKER:
511                continue
512            if not hasattr(obj, key):
513                continue
514            setattr(obj, key, value)
515            changed.append('%s=%s' % (key, value))
516        roles = row.get('roles', IGNORE_MARKER)
517        if roles not in ('', IGNORE_MARKER):
518            evalvalue = eval(roles)
519            if isinstance(evalvalue, list):
520                setattr(obj, 'roles', evalvalue)
521                changed.append('roles=%s' % roles)
522        # Log actions...
523        items_changed = ', '.join(changed)
524        grok.getSite().logger.info('%s - %s - %s - updated: %s'
525            % (self.name, filename, row['name'], items_changed))
526        return
527
528    def checkConversion(self, row, mode='ignore'):
529        """Validates all values in row.
530        """
531        errs, inv_errs, conv_dict = super(
532            UserProcessor, self).checkConversion(row, mode=mode)
533        # We need to check if roles exist.
534        roles = row.get('roles', IGNORE_MARKER)
535        all_roles = [i[0] for i in get_all_roles()]
536        if roles not in ('', IGNORE_MARKER):
537            evalvalue = eval(roles)
538            for role in evalvalue:
539                if role not in all_roles:
540                    errs.append(('roles','invalid role'))
541        return errs, inv_errs, conv_dict
542
543class UsersPlugin(grok.GlobalUtility):
544    """A plugin that updates users.
545    """
546    grok.implements(IIkobaPluggable)
547    grok.name('users')
548
549    deprecated_attributes = []
550
551    def setup(self, site, name, logger):
552        return
553
554    def update(self, site, name, logger):
555        users = site['users']
556        items = getFields(IUserAccount).items()
557        for user in users.values():
558            # Add new attributes
559            for i in items:
560                if not hasattr(user,i[0]):
561                    setattr(user,i[0],i[1].missing_value)
562                    logger.info(
563                        'UsersPlugin: %s attribute %s added.' % (
564                        user.name,i[0]))
565            if not hasattr(user, 'failed_logins'):
566                # add attribute `failed_logins`...
567                user.failed_logins = FailedLoginInfo()
568                logger.info(
569                    'UsersPlugin: attribute failed_logins added.')
570            # Remove deprecated attributes
571            for i in self.deprecated_attributes:
572                try:
573                    delattr(user,i)
574                    logger.info(
575                        'UsersPlugin: %s attribute %s deleted.' % (
576                        user.name,i))
577                except AttributeError:
578                    pass
579        return
Note: See TracBrowser for help on using the repository browser.