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

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

Stored insecure passwords are no longer accepted.
Officers with an insecure password can't login and are
redirected to the ChangePasswordRequestPage to request a
new password.

  • Property svn:keywords set to Id
File size: 23.6 KB
Line 
1## $Id: authentication.py 15287 2019-01-09 21:17:08Z 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(_('Password must have at least 6 chars.')))
413        if pw != pw_repeat:
414            errors.append(translate(_('Passwords do not match.')))
415        return errors
416
417    def validate_secure_password(self, pw, pw_repeat):
418        """
419        ^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{8,}$
420
421        ^              Start anchor
422        (?=.*[A-Z])    Ensure password has one uppercase letters.
423        (?=.*[0-9])    Ensure password has one digit.
424        (?=.*[a-z])    Ensure password has one lowercase letter.
425        .{8,}          Ensure password is of length 8.
426        $              End anchor.
427        """
428
429        # temporarily disabled
430        # /kofa/trunk/src/waeup/kofa/doctests/pages.txt line 176 not met
431        return self.validate_password(pw, pw_repeat)
432
433        check_pw = re.compile(r"^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{8,}$").match
434        errors = []
435        if not check_pw(pw):
436            errors.append(translate(_(
437                'Passwords must be at least 8 characters long, '
438                'must contain at least one uppercase letter, '
439                'one lowercase letter and one digit.')))
440        if pw != pw_repeat:
441            errors.append(translate(_('Passwords do not match.')))
442        return errors
443
444
445class LocalRoleSetEvent(object):
446
447    grok.implements(ILocalRoleSetEvent)
448
449    def __init__(self, object, role_id, principal_id, granted=True):
450        self.object = object
451        self.role_id = role_id
452        self.principal_id = principal_id
453        self.granted = granted
454
455
456@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
457def handle_account_removed(account, event):
458    """When an account is removed, local and global roles might
459    have to be deleted.
460    """
461    local_roles = account.getLocalRoles()
462    principal = account.name
463
464    for role_id, object_list in local_roles.items():
465        for object in object_list:
466            try:
467                role_manager = IPrincipalRoleManager(object)
468            except TypeError:
469                # No Account object, no role manager, no local roles to remove
470                continue
471            role_manager.unsetRoleForPrincipal(role_id, principal)
472    role_manager = IPrincipalRoleManager(grok.getSite())
473    roles = account.getSiteRolesForPrincipal()
474    for role_id in roles:
475        role_manager.unsetRoleForPrincipal(role_id, principal)
476    return
477
478
479@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
480def handle_account_added(account, event):
481    """When an account is added, the local owner role and the global
482    AcademicsOfficer role must be set.
483    """
484    # We set the local Owner role
485    role_manager_account = IPrincipalRoleManager(account)
486    role_manager_account.assignRoleToPrincipal(
487        'waeup.local.Owner', account.name)
488    # We set the global AcademicsOfficer role
489    site = grok.getSite()
490    role_manager_site = IPrincipalRoleManager(site)
491    role_manager_site.assignRoleToPrincipal(
492        'waeup.AcademicsOfficer', account.name)
493    # Finally we have to notify the user account that the local role
494    # of the same object has changed
495    notify(LocalRoleSetEvent(
496        account, 'waeup.local.Owner', account.name, granted=True))
497    return
498
499
500@grok.subscribe(Interface, ILocalRoleSetEvent)
501def handle_local_role_changed(obj, event):
502    site = grok.getSite()
503    if site is None:
504        return
505    users = site.get('users', None)
506    if users is None:
507        return
508    if event.principal_id not in users.keys():
509        return
510    user = users[event.principal_id]
511    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
512    return
513
514
515@grok.subscribe(Interface, grok.IObjectRemovedEvent)
516def handle_local_roles_on_obj_removed(obj, event):
517    try:
518        role_map = IPrincipalRoleMap(obj)
519    except TypeError:
520        # no map, no roles to remove
521        return
522    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
523        notify(LocalRoleSetEvent(
524            obj, local_role, user_name, granted=False))
525    return
526
527
528class UserAccountFactory(grok.GlobalUtility):
529    """A factory for user accounts.
530
531    This factory is only needed for imports.
532    """
533    grok.implements(IFactory)
534    grok.name(u'waeup.UserAccount')
535    title = u"Create a user.",
536    description = u"This factory instantiates new user account instances."
537
538    def __call__(self, *args, **kw):
539        return Account(name=None, password='')
540
541    def getInterfaces(self):
542        return implementedBy(Account)
543
544
545class UserProcessor(BatchProcessor):
546    """The User Processor processes user accounts, i.e. `Account` objects in
547    the ``users`` container.
548
549    The `roles` columns must contain Python list
550    expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``.
551
552    The processor does not import local roles. These can be imported
553    by means of batch processors in the academic section.
554    """
555    grok.implements(IBatchProcessor)
556    grok.provides(IBatchProcessor)
557    grok.context(Interface)
558    util_name = 'userprocessor'
559    grok.name(util_name)
560
561    name = u'User Processor'
562    iface = IUserAccount
563
564    location_fields = ['name', ]
565    factory_name = 'waeup.UserAccount'
566
567    mode = None
568
569    def parentsExist(self, row, site):
570        return 'users' in site.keys()
571
572    def entryExists(self, row, site):
573        return row['name'] in site['users'].keys()
574
575    def getParent(self, row, site):
576        return site['users']
577
578    def getEntry(self, row, site):
579        if not self.entryExists(row, site):
580            return None
581        parent = self.getParent(row, site)
582        return parent.get(row['name'])
583
584    def addEntry(self, obj, row, site):
585        parent = self.getParent(row, site)
586        parent.addAccount(obj)
587        return
588
589    def delEntry(self, row, site):
590        user = self.getEntry(row, site)
591        if user is not None:
592            parent = self.getParent(row, site)
593            grok.getSite().logger.info(
594                '%s - %s - User removed' % (self.name, row['name']))
595            del parent[user.name]
596        pass
597
598    def updateEntry(self, obj, row, site, filename):
599        """Update obj to the values given in row.
600        """
601        changed = []
602        for key, value in row.items():
603            if key == 'roles':
604                # We cannot simply set the roles attribute here because
605                # we can't assure that the name attribute is set before
606                # the roles attribute is set.
607                continue
608            # Skip fields to be ignored.
609            if value == IGNORE_MARKER:
610                continue
611            if not hasattr(obj, key):
612                continue
613            setattr(obj, key, value)
614            changed.append('%s=%s' % (key, value))
615        roles = row.get('roles', IGNORE_MARKER)
616        if roles not in ('', IGNORE_MARKER):
617            evalvalue = eval(roles)
618            if isinstance(evalvalue, list):
619                setattr(obj, 'roles', evalvalue)
620                changed.append('roles=%s' % roles)
621        # Log actions...
622        items_changed = ', '.join(changed)
623        grok.getSite().logger.info(
624            '%s - %s - %s - updated: %s' % (
625                self.name, filename, row['name'], items_changed))
626        return
627
628    def checkConversion(self, row, mode='ignore'):
629        """Validates all values in row.
630        """
631        errs, inv_errs, conv_dict = super(
632            UserProcessor, self).checkConversion(row, mode=mode)
633        # We need to check if roles exist.
634        roles = row.get('roles', IGNORE_MARKER)
635        all_roles = [i[0] for i in get_all_roles()]
636        if roles not in ('', IGNORE_MARKER):
637            evalvalue = eval(roles)
638            for role in evalvalue:
639                if role not in all_roles:
640                    errs.append(('roles', 'invalid role'))
641        return errs, inv_errs, conv_dict
642
643
644class UsersPlugin(grok.GlobalUtility):
645    """A plugin that updates users.
646    """
647    grok.implements(IKofaPluggable)
648    grok.name('users')
649
650    deprecated_attributes = []
651
652    def setup(self, site, name, logger):
653        return
654
655    def update(self, site, name, logger):
656        users = site['users']
657        items = getFields(IUserAccount).items()
658        for user in users.values():
659            # Add new attributes
660            for i in items:
661                if not hasattr(user, i[0]):
662                    setattr(user, i[0], i[1].missing_value)
663                    logger.info(
664                        'UsersPlugin: %s attribute %s added.' % (
665                            user.name, i[0]))
666            if not hasattr(user, 'failed_logins'):
667                # add attribute `failed_logins`...
668                user.failed_logins = FailedLoginInfo()
669                logger.info(
670                    'UsersPlugin: attribute failed_logins added.')
671            # Remove deprecated attributes
672            for i in self.deprecated_attributes:
673                try:
674                    delattr(user, i)
675                    logger.info(
676                        'UsersPlugin: %s attribute %s deleted.' % (
677                            user.name, i))
678                except AttributeError:
679                    pass
680        return
681
682
683class UpdatePAUPlugin(grok.GlobalUtility):
684    """A plugin that updates a local PAU.
685
686    We insert an 'xmlrpc-credentials' PAU-plugin into a sites PAU if it is not
687    present already. There must be 'credentials' plugin registered already.
688
689    XXX: This Plugin fixes a shortcoming of waeup.kofa 1.5. Sites created or
690         updated afterwards do not need this plugin and it should be removed.
691    """
692    grok.implements(IKofaPluggable)
693    grok.name('site-pluggable-auth')
694
695    def setup(self, site, name, logger):
696        return
697
698    def update(self, site, name, logger):
699        pau = site.getSiteManager()['PluggableAuthentication']
700        if 'xmlrpc-credentials' in pau.credentialsPlugins:
701            return
702        plugins = list(pau.credentialsPlugins)
703        plugins.insert(plugins.index('credentials'), 'xmlrpc-credentials')
704        pau.credentialsPlugins = tuple(plugins)
Note: See TracBrowser for help on using the repository browser.