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

Last change on this file since 10802 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
Line 
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##
18"""Authentication for Kofa.
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.kofa.interfaces import (ILocalRoleSetEvent,
38    IUserAccount, IAuthPluginUtility, IPasswordValidator,
39    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable,
40    IBatchProcessor, IGNORE_MARKER, IFailedLoginInfo)
41from waeup.kofa.utils.batching import BatchProcessor
42
43def setup_authentication(pau):
44    """Set up plugguble authentication utility.
45
46    Sets up an IAuthenticatorPlugin and
47    ICredentialsPlugin (for the authentication mechanism)
48
49    Then looks for any external utilities that want to modify the PAU.
50    """
51    pau.credentialsPlugins = ('No Challenge if Authenticated', 'credentials')
52    pau.authenticatorPlugins = ('users',)
53
54    # Give any third-party code and subpackages a chance to modify the PAU
55    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
56    for name, util in auth_plugin_utilities:
57        util.register(pau)
58
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
70class KofaSessionCredentialsPlugin(grok.GlobalUtility,
71                                    SessionCredentialsPlugin):
72    grok.provides(ICredentialsPlugin)
73    grok.name('credentials')
74
75    loginpagename = 'login'
76    loginfield = 'form.login'
77    passwordfield = 'form.password'
78
79class KofaPrincipalInfo(object):
80    """An implementation of IKofaPrincipalInfo.
81
82    A Kofa principal info is created with id, login, title, description,
83    phone, email, public_name and user_type.
84    """
85    grok.implements(IKofaPrincipalInfo)
86
87    def __init__(self, id, title, description, email, phone, public_name,
88                 user_type):
89        self.id = id
90        self.title = title
91        self.description = description
92        self.email = email
93        self.phone = phone
94        self.public_name = public_name
95        self.user_type = user_type
96        self.credentialsPlugin = None
97        self.authenticatorPlugin = None
98
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
109class KofaPrincipal(Principal):
110    """A portal principal.
111
112    Kofa principals provide an extra `email`, `phone`, `public_name`
113    and `user_type` attribute extending ordinary principals.
114    """
115
116    grok.implements(IKofaPrincipal)
117
118    def __init__(self, id, title=u'', description=u'', email=u'',
119                 phone=None, public_name=u'', user_type=u'', prefix=None):
120        self.id = id
121        if prefix is not None:
122            self.id = '%s.%s' % (prefix, self.id)
123        self.title = title
124        self.description = description
125        self.groups = []
126        self.email = email
127        self.phone = phone
128        self.public_name = public_name
129        self.user_type = user_type
130
131    def __repr__(self):
132        return 'KofaPrincipal(%r)' % self.id
133
134class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
135    """Creates 'authenticated' Kofa principals.
136
137    Adapts (principal info, request) to a KofaPrincipal instance.
138
139    This adapter is used by the standard PAU to transform
140    KofaPrincipalInfos into KofaPrincipal instances.
141    """
142    grok.adapts(IKofaPrincipalInfo, IRequest)
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):
150        principal = KofaPrincipal(
151            self.info.id,
152            self.info.title,
153            self.info.description,
154            self.info.email,
155            self.info.phone,
156            self.info.public_name,
157            self.info.user_type,
158            authentication.prefix,
159            )
160        notify(
161            AuthenticatedPrincipalCreated(
162                authentication, principal, self.info, self.request))
163        return principal
164
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
189class Account(grok.Model):
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    """
199    grok.implements(IUserAccount)
200
201    def __init__(self, name, password, title=None, description=None,
202                 email=None, phone=None, public_name=None, roles = []):
203        self.name = name
204        if title is None:
205            title = name
206        self.title = title
207        self.description = description
208        self.email = email
209        self.phone = phone
210        self.public_name = public_name
211        self.setPassword(password)
212        self.setSiteRolesForPrincipal(roles)
213
214        # We don't want to share this dict with other accounts
215        self._local_roles = dict()
216        self.failed_logins = FailedLoginInfo()
217
218    def setPassword(self, password):
219        passwordmanager = getUtility(IPasswordManager, 'SSHA')
220        self.password = passwordmanager.encodePassword(password)
221
222    def checkPassword(self, password):
223        if not isinstance(password, basestring):
224            return False
225        if not self.password:
226            # unset/empty passwords do never match
227            return False
228        passwordmanager = getUtility(IPasswordManager, 'SSHA')
229        return passwordmanager.checkPassword(self.password, password)
230
231    def getSiteRolesForPrincipal(self):
232        prm = get_principal_role_manager()
233        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
234                 if x[0].startswith('waeup.')]
235        return roles
236
237    def setSiteRolesForPrincipal(self, roles):
238        prm = get_principal_role_manager()
239        old_roles = self.getSiteRolesForPrincipal()
240        if sorted(old_roles) == sorted(roles):
241            return
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:
247            # Convert role to ASCII string to be in line with the
248            # event handler
249            prm.assignRoleToPrincipal(str(role), self.name)
250        return
251
252    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
253
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]
266        self._p_changed = True
267        return
268
269class UserAuthenticatorPlugin(grok.GlobalUtility):
270    grok.implements(IAuthenticatorPlugin)
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
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
294        if not account.checkPassword(credentials['password']):
295            #account.failed_logins.increase()
296            return None
297        return KofaPrincipalInfo(
298            id=account.name,
299            title=account.title,
300            description=account.description,
301            email=account.email,
302            phone=account.phone,
303            public_name=account.public_name,
304            user_type=u'user')
305
306    def principalInfo(self, id):
307        account = self.getAccount(id)
308        if account is None:
309            return None
310        return KofaPrincipalInfo(
311            id=account.name,
312            title=account.title,
313            description=account.description,
314            email=account.email,
315            phone=account.phone,
316            public_name=account.public_name,
317            user_type=u'user')
318
319    def getAccount(self, login):
320        # ... look up the account object and return it ...
321        userscontainer = self.getUsersContainer()
322        if userscontainer is None:
323            return
324        return userscontainer.get(login, None)
325
326    def addAccount(self, account):
327        userscontainer = self.getUsersContainer()
328        if userscontainer is None:
329            return
330        # XXX: complain if name already exists...
331        userscontainer.addAccount(account)
332
333    def addUser(self, name, password, title=None, description=None):
334        userscontainer = self.getUsersContainer()
335        if userscontainer is None:
336            return
337        userscontainer.addUser(name, password, title, description)
338
339    def getUsersContainer(self):
340        site = grok.getSite()
341        return site['users']
342
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
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
365@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
366def handle_account_removed(account, event):
367    """When an account is removed, local and global roles might
368    have to be deleted.
369    """
370    local_roles = account.getLocalRoles()
371    principal = account.name
372
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:
378                # No Account object, no role manager, no local roles to remove
379                continue
380            role_manager.unsetRoleForPrincipal(role_id, principal)
381    role_manager = IPrincipalRoleManager(grok.getSite())
382    roles = account.getSiteRolesForPrincipal()
383    for role_id in roles:
384        role_manager.unsetRoleForPrincipal(role_id, principal)
385    return
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
390    AcademicsOfficer role must be set.
391    """
392    # We set the local Owner role
393    role_manager_account = IPrincipalRoleManager(account)
394    role_manager_account.assignRoleToPrincipal(
395        'waeup.local.Owner', account.name)
396    # We set the global AcademicsOfficer role
397    site = grok.getSite()
398    role_manager_site = IPrincipalRoleManager(site)
399    role_manager_site.assignRoleToPrincipal(
400        'waeup.AcademicsOfficer', account.name)
401    # Finally we have to notify the user account that the local role
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))
431    return
432
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
486    def updateEntry(self, obj, row, site, filename):
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)
511        grok.getSite().logger.info('%s - %s - %s - updated: %s'
512            % (self.name, filename, row['name'], items_changed))
513        return
514
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]))
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.')
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
551        return
Note: See TracBrowser for help on using the repository browser.