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

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

Increase password strength. Officers are now required
to set a password which has at least 8 characters, contains
at least one uppercase letter, one lowercase letter and one
digit.

  • Property svn:keywords set to Id
File size: 23.2 KB
Line 
1## $Id: authentication.py 15286 2019-01-09 11:47:37Z 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        if self.suspended:
283            return False
284        passwordmanager = getUtility(IPasswordManager, 'SSHA')
285        return passwordmanager.checkPassword(self.password, password)
286
287    def getSiteRolesForPrincipal(self):
288        prm = get_principal_role_manager()
289        roles = [x[0] for x in prm.getRolesForPrincipal(self.name)
290                 if x[0].startswith('waeup.')]
291        return roles
292
293    def setSiteRolesForPrincipal(self, roles):
294        prm = get_principal_role_manager()
295        old_roles = self.getSiteRolesForPrincipal()
296        if sorted(old_roles) == sorted(roles):
297            return
298        for role in old_roles:
299            # Remove old roles, not to be set now...
300            if role.startswith('waeup.') and role not in roles:
301                prm.unsetRoleForPrincipal(role, self.name)
302        for role in roles:
303            # Convert role to ASCII string to be in line with the
304            # event handler
305            prm.assignRoleToPrincipal(str(role), self.name)
306        return
307
308    roles = property(getSiteRolesForPrincipal, setSiteRolesForPrincipal)
309
310    def getLocalRoles(self):
311        return self._local_roles
312
313    def notifyLocalRoleChanged(self, obj, role_id, granted=True):
314        objects = self._local_roles.get(role_id, [])
315        if granted and obj not in objects:
316            objects.append(obj)
317        if not granted and obj in objects:
318            objects.remove(obj)
319        self._local_roles[role_id] = objects
320        if len(objects) == 0:
321            del self._local_roles[role_id]
322        self._p_changed = True
323        return
324
325
326class UserAuthenticatorPlugin(grok.GlobalUtility):
327    grok.implements(IAuthenticatorPlugin)
328    grok.provides(IAuthenticatorPlugin)
329    grok.name('users')
330
331    def authenticateCredentials(self, credentials):
332        if not isinstance(credentials, dict):
333            return None
334        if not ('login' in credentials and 'password' in credentials):
335            return None
336        account = self.getAccount(credentials['login'])
337        if account is None:
338            return None
339        # The following shows how 'time penalties' could be enforced
340        # on failed logins. First three failed logins are 'for
341        # free'. After that the user has to wait for 1, 2, 4, 8, 16,
342        # 32, ... seconds before a login can succeed.
343        # There are, however, some problems to discuss, before we
344        # really use this in all authenticators.
345
346        #num, last = account.failed_logins.as_tuple()
347        #if (num > 2) and (time.time() < (last + 2**(num-3))):
348        #    # tried login while account still blocked due to previous
349        #    # login errors.
350        #    return None
351        if not account.checkPassword(credentials['password']):
352            #account.failed_logins.increase()
353            return None
354        return KofaPrincipalInfo(
355            id=account.name,
356            title=account.title,
357            description=account.description,
358            email=account.email,
359            phone=account.phone,
360            public_name=account.public_name,
361            user_type=u'user')
362
363    def principalInfo(self, id):
364        account = self.getAccount(id)
365        if account is None:
366            return None
367        return KofaPrincipalInfo(
368            id=account.name,
369            title=account.title,
370            description=account.description,
371            email=account.email,
372            phone=account.phone,
373            public_name=account.public_name,
374            user_type=u'user')
375
376    def getAccount(self, login):
377        # ... look up the account object and return it ...
378        userscontainer = self.getUsersContainer()
379        if userscontainer is None:
380            return
381        return userscontainer.get(login, None)
382
383    def addAccount(self, account):
384        userscontainer = self.getUsersContainer()
385        if userscontainer is None:
386            return
387        # XXX: complain if name already exists...
388        userscontainer.addAccount(account)
389
390    def addUser(self, name, password, title=None, description=None):
391        userscontainer = self.getUsersContainer()
392        if userscontainer is None:
393            return
394        userscontainer.addUser(name, password, title, description)
395
396    def getUsersContainer(self):
397        site = grok.getSite()
398        return site['users']
399
400
401class PasswordValidator(grok.GlobalUtility):
402
403    grok.implements(IPasswordValidator)
404
405    def validate_password(self, pw, pw_repeat):
406        errors = []
407        if len(pw) < 6:
408            errors.append(translate(_('Password must have at least 6 chars.')))
409        if pw != pw_repeat:
410            errors.append(translate(_('Passwords do not match.')))
411        return errors
412
413    def validate_secured_password(self, pw, pw_repeat):
414        """
415        ^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{8,}$
416
417        ^              Start anchor
418        (?=.*[A-Z])    Ensure password has one uppercase letters.
419        (?=.*[0-9])    Ensure password has one digit.
420        (?=.*[a-z])    Ensure password has one lowercase letter.
421        .{8,}          Ensure password is of length 8.
422        $              End anchor.
423        """
424        check_pw = re.compile(r"^(?=.*[A-Z])(?=.*[a-z])(?=.*[0-9]).{8,}$").match
425        errors = []
426        if not check_pw(pw):
427            errors.append(translate(_(
428                'Passwords must be at least 8 characters long, '
429                'contain at least one uppercase letter, '
430                'one lowercase letter and one digit.')))
431        if pw != pw_repeat:
432            errors.append(translate(_('Passwords do not match.')))
433        return errors
434
435
436class LocalRoleSetEvent(object):
437
438    grok.implements(ILocalRoleSetEvent)
439
440    def __init__(self, object, role_id, principal_id, granted=True):
441        self.object = object
442        self.role_id = role_id
443        self.principal_id = principal_id
444        self.granted = granted
445
446
447@grok.subscribe(IUserAccount, grok.IObjectRemovedEvent)
448def handle_account_removed(account, event):
449    """When an account is removed, local and global roles might
450    have to be deleted.
451    """
452    local_roles = account.getLocalRoles()
453    principal = account.name
454
455    for role_id, object_list in local_roles.items():
456        for object in object_list:
457            try:
458                role_manager = IPrincipalRoleManager(object)
459            except TypeError:
460                # No Account object, no role manager, no local roles to remove
461                continue
462            role_manager.unsetRoleForPrincipal(role_id, principal)
463    role_manager = IPrincipalRoleManager(grok.getSite())
464    roles = account.getSiteRolesForPrincipal()
465    for role_id in roles:
466        role_manager.unsetRoleForPrincipal(role_id, principal)
467    return
468
469
470@grok.subscribe(IUserAccount, grok.IObjectAddedEvent)
471def handle_account_added(account, event):
472    """When an account is added, the local owner role and the global
473    AcademicsOfficer role must be set.
474    """
475    # We set the local Owner role
476    role_manager_account = IPrincipalRoleManager(account)
477    role_manager_account.assignRoleToPrincipal(
478        'waeup.local.Owner', account.name)
479    # We set the global AcademicsOfficer role
480    site = grok.getSite()
481    role_manager_site = IPrincipalRoleManager(site)
482    role_manager_site.assignRoleToPrincipal(
483        'waeup.AcademicsOfficer', account.name)
484    # Finally we have to notify the user account that the local role
485    # of the same object has changed
486    notify(LocalRoleSetEvent(
487        account, 'waeup.local.Owner', account.name, granted=True))
488    return
489
490
491@grok.subscribe(Interface, ILocalRoleSetEvent)
492def handle_local_role_changed(obj, event):
493    site = grok.getSite()
494    if site is None:
495        return
496    users = site.get('users', None)
497    if users is None:
498        return
499    if event.principal_id not in users.keys():
500        return
501    user = users[event.principal_id]
502    user.notifyLocalRoleChanged(event.object, event.role_id, event.granted)
503    return
504
505
506@grok.subscribe(Interface, grok.IObjectRemovedEvent)
507def handle_local_roles_on_obj_removed(obj, event):
508    try:
509        role_map = IPrincipalRoleMap(obj)
510    except TypeError:
511        # no map, no roles to remove
512        return
513    for local_role, user_name, setting in role_map.getPrincipalsAndRoles():
514        notify(LocalRoleSetEvent(
515            obj, local_role, user_name, granted=False))
516    return
517
518
519class UserAccountFactory(grok.GlobalUtility):
520    """A factory for user accounts.
521
522    This factory is only needed for imports.
523    """
524    grok.implements(IFactory)
525    grok.name(u'waeup.UserAccount')
526    title = u"Create a user.",
527    description = u"This factory instantiates new user account instances."
528
529    def __call__(self, *args, **kw):
530        return Account(name=None, password='')
531
532    def getInterfaces(self):
533        return implementedBy(Account)
534
535
536class UserProcessor(BatchProcessor):
537    """The User Processor processes user accounts, i.e. `Account` objects in
538    the ``users`` container.
539
540    The `roles` columns must contain Python list
541    expressions like ``['waeup.PortalManager', 'waeup.ImportManager']``.
542
543    The processor does not import local roles. These can be imported
544    by means of batch processors in the academic section.
545    """
546    grok.implements(IBatchProcessor)
547    grok.provides(IBatchProcessor)
548    grok.context(Interface)
549    util_name = 'userprocessor'
550    grok.name(util_name)
551
552    name = u'User Processor'
553    iface = IUserAccount
554
555    location_fields = ['name', ]
556    factory_name = 'waeup.UserAccount'
557
558    mode = None
559
560    def parentsExist(self, row, site):
561        return 'users' in site.keys()
562
563    def entryExists(self, row, site):
564        return row['name'] in site['users'].keys()
565
566    def getParent(self, row, site):
567        return site['users']
568
569    def getEntry(self, row, site):
570        if not self.entryExists(row, site):
571            return None
572        parent = self.getParent(row, site)
573        return parent.get(row['name'])
574
575    def addEntry(self, obj, row, site):
576        parent = self.getParent(row, site)
577        parent.addAccount(obj)
578        return
579
580    def delEntry(self, row, site):
581        user = self.getEntry(row, site)
582        if user is not None:
583            parent = self.getParent(row, site)
584            grok.getSite().logger.info(
585                '%s - %s - User removed' % (self.name, row['name']))
586            del parent[user.name]
587        pass
588
589    def updateEntry(self, obj, row, site, filename):
590        """Update obj to the values given in row.
591        """
592        changed = []
593        for key, value in row.items():
594            if key == 'roles':
595                # We cannot simply set the roles attribute here because
596                # we can't assure that the name attribute is set before
597                # the roles attribute is set.
598                continue
599            # Skip fields to be ignored.
600            if value == IGNORE_MARKER:
601                continue
602            if not hasattr(obj, key):
603                continue
604            setattr(obj, key, value)
605            changed.append('%s=%s' % (key, value))
606        roles = row.get('roles', IGNORE_MARKER)
607        if roles not in ('', IGNORE_MARKER):
608            evalvalue = eval(roles)
609            if isinstance(evalvalue, list):
610                setattr(obj, 'roles', evalvalue)
611                changed.append('roles=%s' % roles)
612        # Log actions...
613        items_changed = ', '.join(changed)
614        grok.getSite().logger.info(
615            '%s - %s - %s - updated: %s' % (
616                self.name, filename, row['name'], items_changed))
617        return
618
619    def checkConversion(self, row, mode='ignore'):
620        """Validates all values in row.
621        """
622        errs, inv_errs, conv_dict = super(
623            UserProcessor, self).checkConversion(row, mode=mode)
624        # We need to check if roles exist.
625        roles = row.get('roles', IGNORE_MARKER)
626        all_roles = [i[0] for i in get_all_roles()]
627        if roles not in ('', IGNORE_MARKER):
628            evalvalue = eval(roles)
629            for role in evalvalue:
630                if role not in all_roles:
631                    errs.append(('roles', 'invalid role'))
632        return errs, inv_errs, conv_dict
633
634
635class UsersPlugin(grok.GlobalUtility):
636    """A plugin that updates users.
637    """
638    grok.implements(IKofaPluggable)
639    grok.name('users')
640
641    deprecated_attributes = []
642
643    def setup(self, site, name, logger):
644        return
645
646    def update(self, site, name, logger):
647        users = site['users']
648        items = getFields(IUserAccount).items()
649        for user in users.values():
650            # Add new attributes
651            for i in items:
652                if not hasattr(user, i[0]):
653                    setattr(user, i[0], i[1].missing_value)
654                    logger.info(
655                        'UsersPlugin: %s attribute %s added.' % (
656                            user.name, i[0]))
657            if not hasattr(user, 'failed_logins'):
658                # add attribute `failed_logins`...
659                user.failed_logins = FailedLoginInfo()
660                logger.info(
661                    'UsersPlugin: attribute failed_logins added.')
662            # Remove deprecated attributes
663            for i in self.deprecated_attributes:
664                try:
665                    delattr(user, i)
666                    logger.info(
667                        'UsersPlugin: %s attribute %s deleted.' % (
668                            user.name, i))
669                except AttributeError:
670                    pass
671        return
672
673
674class UpdatePAUPlugin(grok.GlobalUtility):
675    """A plugin that updates a local PAU.
676
677    We insert an 'xmlrpc-credentials' PAU-plugin into a sites PAU if it is not
678    present already. There must be 'credentials' plugin registered already.
679
680    XXX: This Plugin fixes a shortcoming of waeup.kofa 1.5. Sites created or
681         updated afterwards do not need this plugin and it should be removed.
682    """
683    grok.implements(IKofaPluggable)
684    grok.name('site-pluggable-auth')
685
686    def setup(self, site, name, logger):
687        return
688
689    def update(self, site, name, logger):
690        pau = site.getSiteManager()['PluggableAuthentication']
691        if 'xmlrpc-credentials' in pau.credentialsPlugins:
692            return
693        plugins = list(pau.credentialsPlugins)
694        plugins.insert(plugins.index('credentials'), 'xmlrpc-credentials')
695        pau.credentialsPlugins = tuple(plugins)
Note: See TracBrowser for help on using the repository browser.