source: main/waeup.kofa/trunk/src/waeup/kofa/authentication.py @ 12079

Last change on this file since 12079 was 10055, checked in by uli, 11 years ago

Provide infrastructure to remember failed logins.

  • Property svn:keywords set to Id
File size: 18.6 KB
RevLine 
[7193]1## $Id: authentication.py 10055 2013-04-04 15:12:43Z 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
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
[7811]37from waeup.kofa.interfaces import (ILocalRoleSetEvent,
[7233]38    IUserAccount, IAuthPluginUtility, IPasswordValidator,
[8973]39    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable,
[10055]40    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
[8973]41from waeup.kofa.utils.batching import BatchProcessor
[4073]42
43def setup_authentication(pau):
44    """Set up plugguble authentication utility.
45
46    Sets up an IAuthenticatorPlugin and
47    ICredentialsPlugin (for the authentication mechanism)
[5900]48
49    Then looks for any external utilities that want to modify the PAU.
[4073]50    """
[6661]51    pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials')
[6672]52    pau.authenticatorPlugins = ('users',)
[4073]53
[5900]54    # Give any third-party code and subpackages a chance to modify the PAU
55    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
[5901]56    for name, util in auth_plugin_utilities:
[5900]57        util.register(pau)
58
[6673]59def get_principal_role_manager():
60    """Get a role manager for principals.
61
62    If we are currently 'in a site', return the role manager for the
63    portal or the global rolemanager else.
64    """
65    portal = grok.getSite()
66    if portal is not None:
67        return IPrincipalRoleManager(portal)
68    return principalRoleManager
69
[7819]70class KofaSessionCredentialsPlugin(grok.GlobalUtility,
[4073]71                                    SessionCredentialsPlugin):
72    grok.provides(ICredentialsPlugin)
73    grok.name('credentials')
74
75    loginpagename = 'login'
76    loginfield = 'form.login'
77    passwordfield = 'form.password'
78
[7819]79class KofaPrincipalInfo(object):
80    """An implementation of IKofaPrincipalInfo.
[4073]81
[7819]82    A Kofa principal info is created with id, login, title, description,
[8757]83    phone, email, public_name and user_type.
[7233]84    """
[7819]85    grok.implements(IKofaPrincipalInfo)
[7233]86
[10055]87    def __init__(self, id, title, description, email, phone, public_name,
88                 user_type):
[4073]89        self.id = id
90        self.title = title
91        self.description = description
[7233]92        self.email = email
93        self.phone = phone
[8757]94        self.public_name = public_name
[7240]95        self.user_type = user_type
[4073]96        self.credentialsPlugin = None
97        self.authenticatorPlugin = None
98
[10055]99    def __eq__(self, obj):
100        default = object()
101        result = []
102        for name in ('id', 'title', 'description', 'email', 'phone',
103                     'public_name', 'user_type', 'credentialsPlugin',
104                     'authenticatorPlugin'):
105            result.append(
106                getattr(self, name) == getattr(obj, name, default))
107        return False not in result
108
[7819]109class KofaPrincipal(Principal):
[7233]110    """A portal principal.
111
[10055]112    Kofa principals provide an extra `email`, `phone`, `public_name`
113    and `user_type` attribute extending ordinary principals.
[7233]114    """
115
[7819]116    grok.implements(IKofaPrincipal)
[7233]117
118    def __init__(self, id, title=u'', description=u'', email=u'',
[8757]119                 phone=None, public_name=u'', user_type=u'', prefix=None):
[7233]120        self.id = id
[7239]121        if prefix is not None:
122            self.id = '%s.%s' % (prefix, self.id)
[7233]123        self.title = title
124        self.description = description
125        self.groups = []
126        self.email = email
127        self.phone = phone
[8757]128        self.public_name = public_name
[7240]129        self.user_type = user_type
[7233]130
131    def __repr__(self):
[7819]132        return 'KofaPrincipal(%r)' % self.id
[7233]133
[7819]134class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
135    """Creates 'authenticated' Kofa principals.
[7233]136
[7819]137    Adapts (principal info, request) to a KofaPrincipal instance.
[7233]138
139    This adapter is used by the standard PAU to transform
[7819]140    KofaPrincipalInfos into KofaPrincipal instances.
[7233]141    """
[7819]142    grok.adapts(IKofaPrincipalInfo, IRequest)
[7233]143    grok.implements(IAuthenticatedPrincipalFactory)
144
145    def __init__(self, info, request):
146        self.info = info
147        self.request = request
148
149    def __call__(self, authentication):
[7819]150        principal = KofaPrincipal(
[7233]151            self.info.id,
152            self.info.title,
153            self.info.description,
154            self.info.email,
155            self.info.phone,
[8757]156            self.info.public_name,
[7240]157            self.info.user_type,
[7239]158            authentication.prefix,
[7233]159            )
160        notify(
161            AuthenticatedPrincipalCreated(
162                authentication, principal, self.info, self.request))
163        return principal
164
[10055]165class FailedLoginInfo(grok.Model):
166    grok.implements(IFailedLoginInfo)
167
168    def __init__(self, num=0, last=None):
169        self.num = num
170        self.last = last
171        return
172
173    def as_tuple(self):
174        return (self.num, self.last)
175
176    def set_values(self, num=0, last=None):
177        self.num, self.last = num, last
178        self._p_changed = True
179        pass
180
181    def increase(self):
182        self.set_values(num=self.num + 1, last=time.time())
183        pass
184
185    def reset(self):
186        self.set_values(num=0, last=None)
187        pass
188
[4073]189class Account(grok.Model):
[10055]190    """Kofa user accounts store infos about a user.
191
192    Beside the usual data and an (encrypted) password, accounts also
193    have a persistent attribute `failed_logins` which is an instance
194    of `waeup.kofa.authentication.FailedLoginInfo`.
195
196    This attribute can be manipulated directly (set new value,
197    increase values, or reset).
198    """
[4109]199    grok.implements(IUserAccount)
[4129]200
[4125]201    def __init__(self, name, password, title=None, description=None,
[8756]202                 email=None, phone=None, public_name=None, roles = []):
[4073]203        self.name = name
[4087]204        if title is None:
205            title = name
206        self.title = title
207        self.description = description
[7221]208        self.email = email
[7233]209        self.phone = phone
[8756]210        self.public_name = public_name
[4073]211        self.setPassword(password)
[7636]212        self.setSiteRolesForPrincipal(roles)
[10055]213
[6180]214        # We don't want to share this dict with other accounts
215        self._local_roles = dict()
[10055]216        self.failed_logins = FailedLoginInfo()
[4073]217
218    def setPassword(self, password):
[8343]219        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]220        self.password = passwordmanager.encodePassword(password)
221
222    def checkPassword(self, password):
[8344]223        if not isinstance(password, basestring):
224            return False
225        if not self.password:
226            # unset/empty passwords do never match
227            return False
[8343]228        passwordmanager = getUtility(IPasswordManager, 'SSHA')
[4073]229        return passwordmanager.checkPassword(self.password, password)
230
[7177]231    def getSiteRolesForPrincipal(self):
[6673]232        prm = get_principal_role_manager()
[4129]233        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
234                 if x[0].startswith('waeup.')]
235        return roles
[5055]236
[7177]237    def setSiteRolesForPrincipal(self, roles):
[6673]238        prm = get_principal_role_manager()
[7177]239        old_roles = self.getSiteRolesForPrincipal()
[7658]240        if sorted(old_roles) == sorted(roles):
241            return
[4129]242        for role in old_roles:
243            # Remove old roles, not to be set now...
244            if role.startswith('waeup.') and role not in roles:
245                prm.unsetRoleForPrincipal(role, self.name)
246        for role in roles:
[7658]247            # Convert role to ASCII string to be in line with the
248            # event handler
249            prm.assignRoleToPrincipal(str(role), self.name)
250        return
[4129]251
[7177]252    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
[4129]253
[6180]254    def getLocalRoles(self):
255        return self._local_roles
256
257    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
258        objects = self._local_roles.get(role_id, [])
259        if granted and obj not in objects:
260            objects.append(obj)
261        if not granted and obj in objects:
262            objects.remove(obj)
263        self._local_roles[role_id] = objects
264        if len(objects) == 0:
265            del self._local_roles[role_id]
[6182]266        self._p_changed = True
[6180]267        return
268
[4073]269class UserAuthenticatorPlugin(grok.GlobalUtility):
[6625]270    grok.implements(IAuthenticatorPlugin)
[4073]271    grok.provides(IAuthenticatorPlugin)
272    grok.name('users')
273
274    def authenticateCredentials(self, credentials):
275        if not isinstance(credentials, dict):
276            return None
277        if not ('login' in credentials and 'password' in credentials):
278            return None
279        account = self.getAccount(credentials['login'])
280        if account is None:
281            return None
[10055]282        # The following shows how 'time penalties' could be enforced
283        # on failed logins. First three failed logins are 'for
284        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
285        # 32, ... seconds before a login can succeed.
286        # There are, however, some problems to discuss, before we
287        # really use this in all authenticators.
288
289        #num, last = account.failed_logins.as_tuple()
290        #if (num > 2) and (time.time() < (last + 2**(num-3))):
291        #    # tried login while account still blocked due to previous
292        #    # login errors.
293        #    return None
[4073]294        if not account.checkPassword(credentials['password']):
[10055]295            #account.failed_logins.increase()
[4073]296            return None
[7819]297        return KofaPrincipalInfo(
[7315]298            id=account.name,
299            title=account.title,
300            description=account.description,
301            email=account.email,
302            phone=account.phone,
[8757]303            public_name=account.public_name,
[7315]304            user_type=u'user')
[4073]305
306    def principalInfo(self, id):
307        account = self.getAccount(id)
308        if account is None:
309            return None
[7819]310        return KofaPrincipalInfo(
[7315]311            id=account.name,
312            title=account.title,
313            description=account.description,
314            email=account.email,
315            phone=account.phone,
[8757]316            public_name=account.public_name,
[7315]317            user_type=u'user')
[4073]318
319    def getAccount(self, login):
[4087]320        # ... look up the account object and return it ...
[7172]321        userscontainer = self.getUsersContainer()
322        if userscontainer is None:
[4087]323            return
[7172]324        return userscontainer.get(login, None)
[4087]325
326    def addAccount(self, account):
[7172]327        userscontainer = self.getUsersContainer()
328        if userscontainer is None:
[4087]329            return
330        # XXX: complain if name already exists...
[7172]331        userscontainer.addAccount(account)
[4087]332
[4091]333    def addUser(self, name, password, title=None, description=None):
[7172]334        userscontainer = self.getUsersContainer()
335        if userscontainer is None:
[4091]336            return
[7172]337        userscontainer.addUser(name, password, title, description)
[5055]338
[7172]339    def getUsersContainer(self):
[4087]340        site = grok.getSite()
[4743]341        return site['users']
[6203]342
[7147]343class PasswordValidator(grok.GlobalUtility):
344
345  grok.implements(IPasswordValidator)
346
347  def validate_password(self, pw, pw_repeat):
348       errors = []
349       if len(pw) < 3:
350         errors.append('Password must have at least 3 chars.')
351       if pw != pw_repeat:
352         errors.append('Passwords do not match.')
353       return errors
354
[7169]355class LocalRoleSetEvent(object):
356
357    grok.implements(ILocalRoleSetEvent)
358
359    def __init__(self, object, role_id, principal_id, granted=True):
360        self.object = object
361        self.role_id = role_id
362        self.principal_id = principal_id
363        self.granted = granted
364
[6203]365@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
[6839]366def handle_account_removed(account, event):
[9313]367    """When an account is removed, local and global roles might
368    have to be deleted.
[6203]369    """
370    local_roles = account.getLocalRoles()
371    principal = account.name
[9313]372
[6203]373    for role_id, object_list in local_roles.items():
374        for object in object_list:
375            try:
376                role_manager = IPrincipalRoleManager(object)
377            except TypeError:
[9313]378                # No Account object, no role manager, no local roles to remove
[6203]379                continue
380            role_manager.unsetRoleForPrincipal(role_id, principal)
[9313]381    role_manager = IPrincipalRoleManager(grok.getSite())
382    roles = account.getSiteRolesForPrincipal()
383    for role_id in roles:
384        role_manager.unsetRoleForPrincipal(role_id, principal)
[6203]385    return
[7169]386
387@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
388def handle_account_added(account, event):
389    """When an account is added, the local owner role and the global
[7185]390    AcademicsOfficer role must be set.
[7169]391    """
[7173]392    # We set the local Owner role
393    role_manager_account = IPrincipalRoleManager(account)
394    role_manager_account.assignRoleToPrincipal(
[7169]395        'waeup.local.Owner', account.name)
[7185]396    # We set the global AcademicsOfficer role
[7173]397    site = grok.getSite()
398    role_manager_site = IPrincipalRoleManager(site)
399    role_manager_site.assignRoleToPrincipal(
[7185]400        'waeup.AcademicsOfficer', account.name)
[7173]401    # Finally we have to notify the user account that the local role
[7169]402    # of the same object has changed
403    notify(LocalRoleSetEvent(
404        account, 'waeup.local.Owner', account.name, granted=True))
405    return
406
407@grok.subscribe(Interface, ILocalRoleSetEvent)
408def handle_local_role_changed(obj, event):
409    site = grok.getSite()
410    if site is None:
411        return
412    users = site.get('users', None)
413    if users is None:
414        return
415    if event.principal_id not in users.keys():
416        return
417    user = users[event.principal_id]
418    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
419    return
420
421@grok.subscribe(Interface, grok.IObjectRemovedEvent)
422def handle_local_roles_on_obj_removed(obj, event):
423    try:
424        role_map = IPrincipalRoleMap(obj)
425    except TypeError:
426        # no map, no roles to remove
427        return
428    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
429        notify(LocalRoleSetEvent(
430                obj, local_role, user_name, granted=False))
[7315]431    return
[8756]432
[8973]433class UserAccountFactory(grok.GlobalUtility):
434    """A factory for user accounts.
435
436    This factory is only needed for imports.
437    """
438    grok.implements(IFactory)
439    grok.name(u'waeup.UserAccount')
440    title = u"Create a user.",
441    description = u"This factory instantiates new user account instances."
442
443    def __call__(self, *args, **kw):
444        return Account(name=None, password='')
445
446    def getInterfaces(self):
447        return implementedBy(Account)
448
449class UserProcessor(BatchProcessor):
450    """A batch processor for IUserAccount objects.
451    """
452    grok.implements(IBatchProcessor)
453    grok.provides(IBatchProcessor)
454    grok.context(Interface)
455    util_name = 'userprocessor'
456    grok.name(util_name)
457
458    name = u'User Processor'
459    iface = IUserAccount
460
461    location_fields = ['name',]
462    factory_name = 'waeup.UserAccount'
463
464    mode = None
465
466    def parentsExist(self, row, site):
467        return 'users' in site.keys()
468
469    def entryExists(self, row, site):
470        return row['name'] in site['users'].keys()
471
472    def getParent(self, row, site):
473        return site['users']
474
475    def getEntry(self, row, site):
476        if not self.entryExists(row, site):
477            return None
478        parent = self.getParent(row, site)
479        return parent.get(row['name'])
480
481    def addEntry(self, obj, row, site):
482        parent = self.getParent(row, site)
483        parent.addAccount(obj)
484        return
485
[9706]486    def updateEntry(self, obj, row, site, filename):
[9312]487        """Update obj to the values given in row.
488        """
489        changed = []
490        for key, value in row.items():
491            if  key == 'roles':
492                # We cannot simply set the roles attribute here because
493                # we can't assure that the name attribute is set before
494                # the roles attribute is set.
495                continue
496            # Skip fields to be ignored.
497            if value == IGNORE_MARKER:
498                continue
499            if not hasattr(obj, key):
500                continue
501            setattr(obj, key, value)
502            changed.append('%s=%s' % (key, value))
503        roles = row.get('roles', IGNORE_MARKER)
504        if roles not in ('', IGNORE_MARKER):
505            evalvalue = eval(roles)
506            if isinstance(evalvalue, list):
507                setattr(obj, 'roles', evalvalue)
508                changed.append('roles=%s' % roles)
509        # Log actions...
510        items_changed = ', '.join(changed)
[9706]511        grok.getSite().logger.info('%s - %s - %s - updated: %s'
512            % (self.name, filename, row['name'], items_changed))
[9312]513        return
[8973]514
[8756]515class UsersPlugin(grok.GlobalUtility):
516    """A plugin that updates users.
517    """
518    grok.implements(IKofaPluggable)
519    grok.name('users')
520
521    deprecated_attributes = []
522
523    def setup(self, site, name, logger):
524        return
525
526    def update(self, site, name, logger):
527        users = site['users']
528        items = getFields(IUserAccount).items()
529        for user in users.values():
530            # Add new attributes
531            for i in items:
532                if not hasattr(user,i[0]):
533                    setattr(user,i[0],i[1].missing_value)
534                    logger.info(
535                        'UsersPlugin: %s attribute %s added.' % (
536                        user.name,i[0]))
[10055]537            if not hasattr(user, 'failed_logins'):
538                # add attribute `failed_logins`...
539                user.failed_logins = FailedLoginInfo()
540                logger.info(
541                    'UsersPlugin: attribute failed_logins added.')
[8756]542            # Remove deprecated attributes
543            for i in self.deprecated_attributes:
544                try:
545                    delattr(user,i)
546                    logger.info(
547                        'UsersPlugin: %s attribute %s deleted.' % (
548                        user.name,i))
549                except AttributeError:
550                    pass
[10055]551        return
Note: See TracBrowser for help on using the repository browser.