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

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

Add portal maintenance mode.

See r13394, r13396, r13468.

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