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

Last change on this file since 16251 was 15302, checked in by Henrik Bettermann, 6 years ago

Enable strict password validation.

  • Property svn:keywords set to Id
File size: 23.4 KB
Line 
1## $Id: authentication.py 15302 2019-01-18 09:46:41Z 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 Kofa.
19"""
20import grok
21import time
22import re
23from zope.i18n import translate
24from zope.event import notify
25from zope.component import getUtility, getUtilitiesFor
26from zope.component.interfaces import IFactory
27from zope.interface import Interface, implementedBy
28from zope.schema import getFields
29from zope.securitypolicy.interfaces import (
30    IPrincipalRoleMap, IPrincipalRoleManager)
31from zope.pluggableauth.factories import Principal
32from zope.pluggableauth.plugins.session import SessionCredentialsPlugin
33from zope.pluggableauth.plugins.httpplugins import (
34    HTTPBasicAuthCredentialsPlugin)
35from zope.pluggableauth.interfaces import (
36    ICredentialsPlugin, IAuthenticatorPlugin, IAuthenticatedPrincipalFactory,
37    AuthenticatedPrincipalCreated)
38from zope.publisher.interfaces import IRequest
39from zope.password.interfaces import IPasswordManager
40from zope.securitypolicy.principalrole import principalRoleManager
41from waeup.kofa.interfaces import (
42    ILocalRoleSetEvent, IUserAccount, IAuthPluginUtility, IPasswordValidator,
43    IKofaPrincipal, IKofaPrincipalInfo, IKofaPluggable, IBatchProcessor,
44    IGNORE_MARKER, IFailedLoginInfo)
45from waeup.kofa.utils.batching import BatchProcessor
46from waeup.kofa.permissions import get_all_roles
47from waeup.kofa.interfaces import MessageFactory as _
48
49
50def setup_authentication(pau):
51    """Set up plugguble authentication utility.
52
53    Sets up an IAuthenticatorPlugin and
54    ICredentialsPlugin (for the authentication mechanism)
55
56    Then looks for any external utilities that want to modify the PAU.
57    """
58    pau.credentialsPlugins = (
59        'No Challenge if Authenticated',
60        'xmlrpc-credentials',
61        'credentials')
62    pau.authenticatorPlugins = ('users',)
63
64    # Give any third-party code and subpackages a chance to modify the PAU
65    auth_plugin_utilities = getUtilitiesFor(IAuthPluginUtility)
66    for name, util in auth_plugin_utilities:
67        util.register(pau)
68
69
70def get_principal_role_manager():
71    """Get a role manager for principals.
72
73    If we are currently 'in a site', return the role manager for the
74    portal or the global rolemanager else.
75    """
76    portal = grok.getSite()
77    if portal is not None:
78        return IPrincipalRoleManager(portal)
79    return principalRoleManager
80
81
82class KofaSessionCredentialsPlugin(
83        grok.GlobalUtility, SessionCredentialsPlugin):
84    """Session plugin that picks usernames/passwords from fields in webforms.
85    """
86    grok.provides(ICredentialsPlugin)
87    grok.name('credentials')
88
89    loginpagename = 'login'
90    loginfield = 'form.login'
91    passwordfield = 'form.password'
92
93
94class KofaXMLRPCCredentialsPlugin(
95        grok.GlobalUtility, HTTPBasicAuthCredentialsPlugin):
96    """Plugin that picks useranams/passwords from basic-auth headers.
97
98    As XMLRPC requests send/post their authentication credentials in HTTP
99    basic-auth headers, we need a plugin that can handle this.
100
101    This plugin, however, does no challenging. If a user does not provide
102    basic-auth infos, we will not ask for some. This is correct as we plan to
103    communicate with machines.
104
105    This plugin is planned to be used in "PluggableAuthenitications" registered
106    with `University` instances.
107    """
108    grok.provides(ICredentialsPlugin)
109    grok.name('xmlrpc-credentials')
110
111    def challenge(self, request):
112        """XMLRPC is for machines. No need to challenge.
113        """
114        return False
115
116    def logout(self, request):
117        """Basic auth does not provide any logout possibility.
118        """
119        return False
120
121
122class KofaPrincipalInfo(object):
123    """An implementation of IKofaPrincipalInfo.
124
125    A Kofa principal info is created with id, login, title, description,
126    phone, email, public_name and user_type.
127    """
128    grok.implements(IKofaPrincipalInfo)
129
130    def __init__(self, id, title, description, email, phone, public_name,
131                 user_type):
132        self.id = id
133        self.title = title
134        self.description = description
135        self.email = email
136        self.phone = phone
137        self.public_name = public_name
138        self.user_type = user_type
139        self.credentialsPlugin = None
140        self.authenticatorPlugin = None
141
142    def __eq__(self, obj):
143        default = object()
144        result = []
145        for name in ('id', 'title', 'description', 'email', 'phone',
146                     'public_name', 'user_type', 'credentialsPlugin',
147                     'authenticatorPlugin'):
148            result.append(
149                getattr(self, name) == getattr(obj, name, default))
150        return False not in result
151
152
153class KofaPrincipal(Principal):
154    """A portal principal.
155
156    Kofa principals provide an extra `email`, `phone`, `public_name`
157    and `user_type` attribute extending ordinary principals.
158    """
159
160    grok.implements(IKofaPrincipal)
161
162    def __init__(self, id, title=u'', description=u'', email=u'',
163                 phone=None, public_name=u'', user_type=u'', prefix=None):
164        self.id = id
165        if prefix is not None:
166            self.id = '%s.%s' % (prefix, self.id)
167        self.title = title
168        self.description = description
169        self.groups = []
170        self.email = email
171        self.phone = phone
172        self.public_name = public_name
173        self.user_type = user_type
174
175    def __repr__(self):
176        return 'KofaPrincipal(%r)' % self.id
177
178
179class AuthenticatedKofaPrincipalFactory(grok.MultiAdapter):
180    """Creates 'authenticated' Kofa principals.
181
182    Adapts (principal info, request) to a KofaPrincipal instance.
183
184    This adapter is used by the standard PAU to transform
185    KofaPrincipalInfos into KofaPrincipal instances.
186    """
187    grok.adapts(IKofaPrincipalInfo, IRequest)
188    grok.implements(IAuthenticatedPrincipalFactory)
189
190    def __init__(self, info, request):
191        self.info = info
192        self.request = request
193
194    def __call__(self, authentication):
195        principal = KofaPrincipal(
196            self.info.id,
197            self.info.title,
198            self.info.description,
199            self.info.email,
200            self.info.phone,
201            self.info.public_name,
202            self.info.user_type,
203            authentication.prefix,
204            )
205        notify(
206            AuthenticatedPrincipalCreated(
207                authentication, principal, self.info, self.request))
208        return principal
209
210
211class FailedLoginInfo(grok.Model):
212    grok.implements(IFailedLoginInfo)
213
214    def __init__(self, num=0, last=None):
215        self.num = num
216        self.last = last
217        return
218
219    def as_tuple(self):
220        return (self.num, self.last)
221
222    def set_values(self, num=0, last=None):
223        self.num, self.last = num, last
224        self._p_changed = True
225        pass
226
227    def increase(self):
228        self.set_values(num=self.num + 1, last=time.time())
229        pass
230
231    def reset(self):
232        self.set_values(num=0, last=None)
233        pass
234
235
236class Account(grok.Model):
237    """Kofa user accounts store infos about a user.
238
239    Beside the usual data and an (encrypted) password, accounts also
240    have a persistent attribute `failed_logins` which is an instance
241    of `waeup.kofa.authentication.FailedLoginInfo`.
242
243    This attribute can be manipulated directly (set new value,
244    increase values, or reset).
245    """
246    grok.implements(IUserAccount)
247
248    def __init__(self, name, password, title=None, description=None,
249                 email=None, phone=None, public_name=None, roles=[]):
250        self.name = name
251        if title is None:
252            title = name
253        self.title = title
254        self.description = description
255        self.email = email
256        self.phone = phone
257        self.public_name = public_name
258        self.suspended = False
259        self.setPassword(password)
260        self.setSiteRolesForPrincipal(roles)
261
262        # We don't want to share this dict with other accounts
263        self._local_roles = dict()
264        self.failed_logins = FailedLoginInfo()
265
266    def setPassword(self, password):
267        passwordmanager = getUtility(IPasswordManager, 'SSHA')
268        self.password = passwordmanager.encodePassword(password)
269
270    def checkPassword(self, password):
271        try:
272            blocker = grok.getSite()['configuration'].maintmode_enabled_by
273            if blocker and blocker != self.name:
274                return False
275        except (TypeError, KeyError):  # in unit tests
276            pass
277        if not isinstance(password, basestring):
278            return False
279        if not self.password:
280            # unset/empty passwords do never match
281            return False
282        # Do not accept password if password is insecure.
283        validator = getUtility(IPasswordValidator)
284        if validator.validate_secure_password(password, password):
285            return False
286        if self.suspended:
287            return False
288        passwordmanager = getUtility(IPasswordManager, 'SSHA')
289        return passwordmanager.checkPassword(self.password, password)
290
291    def getSiteRolesForPrincipal(self):
292        prm = get_principal_role_manager()
293        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
294                 if x[0].startswith('waeup.')]
295        return roles
296
297    def setSiteRolesForPrincipal(self, roles):
298        prm = get_principal_role_manager()
299        old_roles = self.getSiteRolesForPrincipal()
300        if sorted(old_roles) == sorted(roles):
301            return
302        for role in old_roles:
303            # Remove old roles, not to be set now...
304            if role.startswith('waeup.') and role not in roles:
305                prm.unsetRoleForPrincipal(role, self.name)
306        for role in roles:
307            # Convert role to ASCII string to be in line with the
308            # event handler
309            prm.assignRoleToPrincipal(str(role), self.name)
310        return
311
312    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
313
314    def getLocalRoles(self):
315        return self._local_roles
316
317    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
318        objects = self._local_roles.get(role_id, [])
319        if granted and obj not in objects:
320            objects.append(obj)
321        if not granted and obj in objects:
322            objects.remove(obj)
323        self._local_roles[role_id] = objects
324        if len(objects) == 0:
325            del self._local_roles[role_id]
326        self._p_changed = True
327        return
328
329
330class UserAuthenticatorPlugin(grok.GlobalUtility):
331    grok.implements(IAuthenticatorPlugin)
332    grok.provides(IAuthenticatorPlugin)
333    grok.name('users')
334
335    def authenticateCredentials(self, credentials):
336        if not isinstance(credentials, dict):
337            return None
338        if not ('login' in credentials and 'password' in credentials):
339            return None
340        account = self.getAccount(credentials['login'])
341        if account is None:
342            return None
343        # The following shows how 'time penalties' could be enforced
344        # on failed logins. First three failed logins are 'for
345        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
346        # 32, ... seconds before a login can succeed.
347        # There are, however, some problems to discuss, before we
348        # really use this in all authenticators.
349
350        #num, last = account.failed_logins.as_tuple()
351        #if (num > 2) and (time.time() < (last + 2**(num-3))):
352        #    # tried login while account still blocked due to previous
353        #    # login errors.
354        #    return None
355        if not account.checkPassword(credentials['password']):
356            #account.failed_logins.increase()
357            return None
358        return KofaPrincipalInfo(
359            id=account.name,
360            title=account.title,
361            description=account.description,
362            email=account.email,
363            phone=account.phone,
364            public_name=account.public_name,
365            user_type=u'user')
366
367    def principalInfo(self, id):
368        account = self.getAccount(id)
369        if account is None:
370            return None
371        return KofaPrincipalInfo(
372            id=account.name,
373            title=account.title,
374            description=account.description,
375            email=account.email,
376            phone=account.phone,
377            public_name=account.public_name,
378            user_type=u'user')
379
380    def getAccount(self, login):
381        # ... look up the account object and return it ...
382        userscontainer = self.getUsersContainer()
383        if userscontainer is None:
384            return
385        return userscontainer.get(login, None)
386
387    def addAccount(self, account):
388        userscontainer = self.getUsersContainer()
389        if userscontainer is None:
390            return
391        # XXX: complain if name already exists...
392        userscontainer.addAccount(account)
393
394    def addUser(self, name, password, title=None, description=None):
395        userscontainer = self.getUsersContainer()
396        if userscontainer is None:
397            return
398        userscontainer.addUser(name, password, title, description)
399
400    def getUsersContainer(self):
401        site = grok.getSite()
402        return site['users']
403
404
405class PasswordValidator(grok.GlobalUtility):
406
407    grok.implements(IPasswordValidator)
408
409    def validate_password(self, pw, pw_repeat):
410        errors = []
411        if len(pw) < 6:
412            errors.append(translate(_(
413                'Password must have at least 6 characters.')))
414        if pw != pw_repeat:
415            errors.append(translate(_('Passwords do not match.')))
416        return errors
417
418    def validate_secure_password(self, pw, pw_repeat):
419        """
420        ^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{8,}$
421
422        ^              Start anchor
423        (?=.*[A-Z])    Ensure password has one uppercase letters.
424        (?=.*[0-9])    Ensure password has one digit.
425        (?=.*[a-z])    Ensure password has one lowercase letter.
426        .{8,}          Ensure password is of length 8.
427        $              End anchor
428        """
429        check_pw = re.compile(r"^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{8,}$").match
430        errors = []
431        if not check_pw(pw):
432            errors.append(translate(_(
433                'Passwords must be at least 8 characters long, '
434                'must contain at least one uppercase letter, '
435                'one lowercase letter and one digit.')))
436        if pw != pw_repeat:
437            errors.append(translate(_('Passwords do not match.')))
438        return errors
439
440
441class LocalRoleSetEvent(object):
442
443    grok.implements(ILocalRoleSetEvent)
444
445    def __init__(self, object, role_id, principal_id, granted=True):
446        self.object = object
447        self.role_id = role_id
448        self.principal_id = principal_id
449        self.granted = granted
450
451
452@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
453def handle_account_removed(account, event):
454    """When an account is removed, local and global roles might
455    have to be deleted.
456    """
457    local_roles = account.getLocalRoles()
458    principal = account.name
459
460    for role_id, object_list in local_roles.items():
461        for object in object_list:
462            try:
463                role_manager = IPrincipalRoleManager(object)
464            except TypeError:
465                # No Account object, no role manager, no local roles to remove
466                continue
467            role_manager.unsetRoleForPrincipal(role_id, principal)
468    role_manager = IPrincipalRoleManager(grok.getSite())
469    roles = account.getSiteRolesForPrincipal()
470    for role_id in roles:
471        role_manager.unsetRoleForPrincipal(role_id, principal)
472    return
473
474
475@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
476def handle_account_added(account, event):
477    """When an account is added, the local owner role and the global
478    AcademicsOfficer role must be set.
479    """
480    # We set the local Owner role
481    role_manager_account = IPrincipalRoleManager(account)
482    role_manager_account.assignRoleToPrincipal(
483        'waeup.local.Owner', account.name)
484    # We set the global AcademicsOfficer role
485    site = grok.getSite()
486    role_manager_site = IPrincipalRoleManager(site)
487    role_manager_site.assignRoleToPrincipal(
488        'waeup.AcademicsOfficer', account.name)
489    # Finally we have to notify the user account that the local role
490    # of the same object has changed
491    notify(LocalRoleSetEvent(
492        account, 'waeup.local.Owner', account.name, granted=True))
493    return
494
495
496@grok.subscribe(Interface, ILocalRoleSetEvent)
497def handle_local_role_changed(obj, event):
498    site = grok.getSite()
499    if site is None:
500        return
501    users = site.get('users', None)
502    if users is None:
503        return
504    if event.principal_id not in users.keys():
505        return
506    user = users[event.principal_id]
507    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
508    return
509
510
511@grok.subscribe(Interface, grok.IObjectRemovedEvent)
512def handle_local_roles_on_obj_removed(obj, event):
513    try:
514        role_map = IPrincipalRoleMap(obj)
515    except TypeError:
516        # no map, no roles to remove
517        return
518    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
519        notify(LocalRoleSetEvent(
520            obj, local_role, user_name, granted=False))
521    return
522
523
524class UserAccountFactory(grok.GlobalUtility):
525    """A factory for user accounts.
526
527    This factory is only needed for imports.
528    """
529    grok.implements(IFactory)
530    grok.name(u'waeup.UserAccount')
531    title = u"Create a user.",
532    description = u"This factory instantiates new user account instances."
533
534    def __call__(self, *args, **kw):
535        return Account(name=None, password='')
536
537    def getInterfaces(self):
538        return implementedBy(Account)
539
540
541class UserProcessor(BatchProcessor):
542    """The User Processor processes user accounts, i.e. `Account` objects in
543    the ``users`` container.
544
545    The `roles` columns must contain Python list
546    expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``.
547
548    The processor does not import local roles. These can be imported
549    by means of batch processors in the academic section.
550    """
551    grok.implements(IBatchProcessor)
552    grok.provides(IBatchProcessor)
553    grok.context(Interface)
554    util_name = 'userprocessor'
555    grok.name(util_name)
556
557    name = u'User Processor'
558    iface = IUserAccount
559
560    location_fields = ['name', ]
561    factory_name = 'waeup.UserAccount'
562
563    mode = None
564
565    def parentsExist(self, row, site):
566        return 'users' in site.keys()
567
568    def entryExists(self, row, site):
569        return row['name'] in site['users'].keys()
570
571    def getParent(self, row, site):
572        return site['users']
573
574    def getEntry(self, row, site):
575        if not self.entryExists(row, site):
576            return None
577        parent = self.getParent(row, site)
578        return parent.get(row['name'])
579
580    def addEntry(self, obj, row, site):
581        parent = self.getParent(row, site)
582        parent.addAccount(obj)
583        return
584
585    def delEntry(self, row, site):
586        user = self.getEntry(row, site)
587        if user is not None:
588            parent = self.getParent(row, site)
589            grok.getSite().logger.info(
590                '%s - %s - User removed' % (self.name, row['name']))
591            del parent[user.name]
592        pass
593
594    def updateEntry(self, obj, row, site, filename):
595        """Update obj to the values given in row.
596        """
597        changed = []
598        for key, value in row.items():
599            if key == 'roles':
600                # We cannot simply set the roles attribute here because
601                # we can't assure that the name attribute is set before
602                # the roles attribute is set.
603                continue
604            # Skip fields to be ignored.
605            if value == IGNORE_MARKER:
606                continue
607            if not hasattr(obj, key):
608                continue
609            setattr(obj, key, value)
610            changed.append('%s=%s' % (key, value))
611        roles = row.get('roles', IGNORE_MARKER)
612        if roles not in ('', IGNORE_MARKER):
613            evalvalue = eval(roles)
614            if isinstance(evalvalue, list):
615                setattr(obj, 'roles', evalvalue)
616                changed.append('roles=%s' % roles)
617        # Log actions...
618        items_changed = ', '.join(changed)
619        grok.getSite().logger.info(
620            '%s - %s - %s - updated: %s' % (
621                self.name, filename, row['name'], items_changed))
622        return
623
624    def checkConversion(self, row, mode='ignore'):
625        """Validates all values in row.
626        """
627        errs, inv_errs, conv_dict = super(
628            UserProcessor, self).checkConversion(row, mode=mode)
629        # We need to check if roles exist.
630        roles = row.get('roles', IGNORE_MARKER)
631        all_roles = [i[0] for i in get_all_roles()]
632        if roles not in ('', IGNORE_MARKER):
633            evalvalue = eval(roles)
634            for role in evalvalue:
635                if role not in all_roles:
636                    errs.append(('roles', 'invalid role'))
637        return errs, inv_errs, conv_dict
638
639
640class UsersPlugin(grok.GlobalUtility):
641    """A plugin that updates users.
642    """
643    grok.implements(IKofaPluggable)
644    grok.name('users')
645
646    deprecated_attributes = []
647
648    def setup(self, site, name, logger):
649        return
650
651    def update(self, site, name, logger):
652        users = site['users']
653        items = getFields(IUserAccount).items()
654        for user in users.values():
655            # Add new attributes
656            for i in items:
657                if not hasattr(user, i[0]):
658                    setattr(user, i[0], i[1].missing_value)
659                    logger.info(
660                        'UsersPlugin: %s attribute %s added.' % (
661                            user.name, i[0]))
662            if not hasattr(user, 'failed_logins'):
663                # add attribute `failed_logins`...
664                user.failed_logins = FailedLoginInfo()
665                logger.info(
666                    'UsersPlugin: attribute failed_logins added.')
667            # Remove deprecated attributes
668            for i in self.deprecated_attributes:
669                try:
670                    delattr(user, i)
671                    logger.info(
672                        'UsersPlugin: %s attribute %s deleted.' % (
673                            user.name, i))
674                except AttributeError:
675                    pass
676        return
677
678
679class UpdatePAUPlugin(grok.GlobalUtility):
680    """A plugin that updates a local PAU.
681
682    We insert an 'xmlrpc-credentials' PAU-plugin into a sites PAU if it is not
683    present already. There must be 'credentials' plugin registered already.
684
685    XXX: This Plugin fixes a shortcoming of waeup.kofa 1.5. Sites created or
686         updated afterwards do not need this plugin and it should be removed.
687    """
688    grok.implements(IKofaPluggable)
689    grok.name('site-pluggable-auth')
690
691    def setup(self, site, name, logger):
692        return
693
694    def update(self, site, name, logger):
695        pau = site.getSiteManager()['PluggableAuthentication']
696        if 'xmlrpc-credentials' in pau.credentialsPlugins:
697            return
698        plugins = list(pau.credentialsPlugins)
699        plugins.insert(plugins.index('credentials'), 'xmlrpc-credentials')
700        pau.credentialsPlugins = tuple(plugins)
Note: See TracBrowser for help on using the repository browser.