Projet

Général

Profil

0001-misc-add-support-for-enable-conditions-on-authentica.patch

Serghei Mihai, 16 janvier 2020 16:20

Télécharger (18,6 ko)

Voir les différences:

Subject: [PATCH] misc: add support for enable conditions on authentication
 frontends (#28215)

 src/authentic2/app_settings.py             |  1 +
 src/authentic2/authenticators.py           | 46 ++++++++++++++++++++--
 src/authentic2/idp/saml/saml2_endpoints.py |  6 ++-
 src/authentic2/saml/common.py              | 11 ++++++
 src/authentic2/templatetags/__init__.py    |  0
 src/authentic2/templatetags/authentic2.py  |  7 ++++
 src/authentic2/utils/__init__.py           |  4 +-
 src/authentic2/views.py                    |  4 +-
 src/authentic2_auth_fc/authenticators.py   | 10 +++--
 src/authentic2_auth_oidc/authenticators.py | 14 +++++--
 src/authentic2_auth_saml/authenticators.py | 21 +++++++---
 tests/test_auth_oidc.py                    | 41 +++++++++++++++++++
 tests/test_auth_saml.py                    | 39 ++++++++++++++++++
 tests/test_login.py                        | 24 ++++++++++-
 14 files changed, 208 insertions(+), 20 deletions(-)
 create mode 100644 src/authentic2/templatetags/__init__.py
 create mode 100644 src/authentic2/templatetags/authentic2.py
src/authentic2/app_settings.py
239 239
    A2_AUTH_PASSWORD_ENABLE=Setting(
240 240
        default=True,
241 241
        definition='Activate login/password authentication', names=('AUTH_PASSWORD',)),
242
    A2_AUTH_MODULES_CONDITIONS=Setting(default={}, definition='Modules filters'),
242 243
    A2_LOGIN_FAILURE_COUNT_BEFORE_WARNING=Setting(
243 244
        default=0,
244 245
        definition='Failure count before logging a warning to '
src/authentic2/authenticators.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17

  
18
import logging
19

  
17 20
from django.db.models import Count
18 21
from django.shortcuts import render
19 22
from django.utils.translation import ugettext as _, ugettext_lazy
23
from django.template import Template, TemplateSyntaxError, RequestContext
20 24

  
21 25
from authentic2.a2_rbac.models import OrganizationalUnit as OU, Role
22 26
from authentic2.custom_user.models import User
27

  
23 28
from . import views, app_settings, utils, constants, models
24 29
from .forms import authentication as authentication_forms
25 30

  
26 31

  
27
class LoginPasswordAuthenticator(object):
32
class BaseAuthenticator(object):
33

  
34
    def eval_condition(self, condition, request):
35
        try:
36
            template = Template("{%% load authentic2 %%}{%% if %s %%}OK{%% endif %%}" % condition)
37
            return template.render(RequestContext(request)) == "OK"
38
        except TemplateSyntaxError as e:
39
            logger = logging.getLogger()
40
            logger.error(e)
41
            return False
42

  
43
    def is_condition_enabled(self, request):
44
        if not (app_settings.A2_AUTH_MODULES_CONDITIONS and
45
                self.id in app_settings.A2_AUTH_MODULES_CONDITIONS):
46
            return True
47
        return self.eval_condition(app_settings.A2_AUTH_MODULES_CONDITIONS[self.id],
48
                                   request)
49

  
50
    def get_enabled_references(self, request):
51
        refs = []
52
        if app_settings.A2_AUTH_MODULES_CONDITIONS:
53
            for key, condition in app_settings.A2_AUTH_MODULES_CONDITIONS.items():
54
                if '/' not in key:
55
                    continue
56
                id_, ref =  key.split('/', 1)
57
                if self.id != id_:
58
                    continue
59
                if not self.eval_condition(condition, request):
60
                    refs.append(ref)
61
        return refs
62

  
63

  
64
class LoginPasswordAuthenticator(BaseAuthenticator):
28 65
    submit_name = 'login-password-submit'
29 66

  
30
    def enabled(self):
31
        return app_settings.A2_AUTH_PASSWORD_ENABLE
67
    def enabled(self, request=None):
68
        if not app_settings.A2_AUTH_PASSWORD_ENABLE:
69
            return False
70
        return self.is_condition_enabled(request)
32 71

  
33 72
    def name(self):
34 73
        return ugettext_lazy('Password')
35 74

  
75
    @property
36 76
    def id(self):
37 77
        return 'password'
38 78

  
src/authentic2/idp/saml/saml2_endpoints.py
83 83
    send_soap_request, get_saml2_query_request, \
84 84
    get_saml2_request_message_async_binding, create_saml2_server, \
85 85
    get_saml2_metadata, get_sp_options_policy, \
86
    get_entity_id, AUTHENTIC_SAME_ID_SENTINEL
86
    get_entity_id, AUTHENTIC_SAME_ID_SENTINEL, \
87
    get_extension_node
87 88
import authentic2.saml.saml2utils as saml2utils
88 89
from authentic2.idp.saml.common import kill_django_sessions
89 90
from authentic2.constants import NONCE_FIELD_NAME
......
534 535
            name_id_policy = login.request.nameIdPolicy
535 536
        name_id_policy.format = NAME_ID_FORMATS[nid_format]['samlv2']
536 537
        logger.debug('set nameID policy format %s', nid_format)
538
    hint = get_extension_node(login.request.extensions, 'login-hint')
539
    if hint is not None:
540
        request.session['login-hint'] = hint.text
537 541
    return sso_after_process_request(request, login, nid_format=nid_format)
538 542

  
539 543

  
src/authentic2/saml/common.py
18 18
import logging
19 19
import re
20 20
import datetime
21
import xml.etree.ElementTree as ET
21 22

  
22 23
import requests
23 24

  
......
39 40
from authentic2.idp.saml import app_settings
40 41
from .. import nonce
41 42

  
43

  
44

  
45
EO_NS = 'https://www.entrouvert.com/'
46

  
42 47
AUTHENTIC_STATUS_CODE_NS = "http://authentic.entrouvert.org/status_code/"
43 48
AUTHENTIC_SAME_ID_SENTINEL = 'urn:authentic.entrouvert.org:same-as-provider-entity-id'
44 49
AUTHENTIC_STATUS_CODE_UNKNOWN_PROVIDER = AUTHENTIC_STATUS_CODE_NS + "UnknownProvider"
......
586 591
    if session_not_on_or_afters:
587 592
        return six.moves.reduce(min, session_not_on_or_afters)
588 593
    return None
594

  
595
def get_extension_node(extensions, node, ns=EO_NS):
596
    if extensions is not None:
597
        root = ET.fromstring(extensions.dump())
598
        return root.find('{%s}%s' % (ns, node))
599
    return None
src/authentic2/templatetags/authentic2.py
1
from django.template import Library
2

  
3
register = Library()
4

  
5
@register.filter
6
def is_for_backoffice(request):
7
    return request.session.get('login-hint') == 'backoffice'
src/authentic2/utils/__init__.py
169 169
    return cls()
170 170

  
171 171

  
172
def get_backends(setting_name='IDP_BACKENDS'):
172
def get_backends(setting_name='IDP_BACKENDS', request=None):
173 173
    '''Return the list of enabled cleaned backends.'''
174 174
    backends = []
175 175
    for backend_path in getattr(app_settings, setting_name):
......
178 178
            backend_path, kwargs = backend_path
179 179
        backend = load_backend(backend_path)
180 180
        # If no enabled method is defined on the backend, backend enabled by default.
181
        if hasattr(backend, 'enabled') and not backend.enabled():
181
        if hasattr(backend, 'enabled') and not backend.enabled(request):
182 182
            continue
183 183
        kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
184 184
        if backend_path in kwargs_settings:
src/authentic2/views.py
278 278
            redirect_to = settings.LOGIN_REDIRECT_URL
279 279
    nonce = request.GET.get(constants.NONCE_FIELD_NAME)
280 280

  
281
    authenticators = utils.get_backends('AUTH_FRONTENDS')
281
    authenticators = utils.get_backends('AUTH_FRONTENDS', request)
282 282

  
283 283
    blocks = []
284 284

  
......
809 809
        parameters = {'request': self.request,
810 810
                      'context': context}
811 811
        blocks = [utils.get_authenticator_method(authenticator, 'registration', parameters)
812
                  for authenticator in utils.get_backends('AUTH_FRONTENDS')]
812
                  for authenticator in utils.get_backends('AUTH_FRONTENDS', self.request)]
813 813
        context['frontends'] = collections.OrderedDict((block['id'], block)
814 814
                                                       for block in blocks if block)
815 815
        return context
src/authentic2_auth_fc/authenticators.py
19 19
from django.template.response import TemplateResponse
20 20

  
21 21
from authentic2 import app_settings as a2_app_settings, utils as a2_utils
22
from authentic2.authenticators import BaseAuthenticator
22 23

  
23 24
from . import app_settings
24 25

  
25 26

  
26
class FcAuthenticator(object):
27
    def enabled(self):
28
        return app_settings.enable
27
class FcAuthenticator(BaseAuthenticator):
28
    def enabled(self, request):
29
        if not app_settings.enable:
30
            return False
31
        return self.is_condition_enabled(request)
29 32

  
30 33
    def name(self):
31 34
        return gettext_noop('FranceConnect')
32 35

  
36
    @property
33 37
    def id(self):
34 38
        return 'fc'
35 39

  
src/authentic2_auth_oidc/authenticators.py
19 19

  
20 20
from . import app_settings, utils
21 21
from authentic2.utils import make_url
22
from authentic2.authenticators import BaseAuthenticator
22 23

  
23 24

  
24
class OIDCAuthenticator(object):
25
    def enabled(self):
25
class OIDCAuthenticator(BaseAuthenticator):
26
    def enabled(self, request=None):
26 27
        return app_settings.ENABLE and utils.has_providers()
27 28

  
28 29
    def name(self):
29 30
        return gettext_noop('OpenIDConnect')
30 31

  
32
    @property
31 33
    def id(self):
32 34
        return 'oidc'
33 35

  
36
    def filter_by_condition(self, request, providers):
37
        refs = self.get_enabled_references(request)
38
        if refs:
39
            providers = providers.exclude(slug__in=refs)
40
        return providers
41

  
34 42
    def instances(self, request, *args, **kwargs):
35
        for p in utils.get_providers(shown=True):
43
        for p in self.filter_by_condition(request, utils.get_providers(shown=True)):
36 44
            yield (p.slug, p)
37 45

  
38 46
    def login(self, request, *args, **kwargs):
src/authentic2_auth_saml/authenticators.py
20 20
from mellon.utils import get_idp, get_idps
21 21

  
22 22
from authentic2.utils import redirect_to_login
23
from authentic2.authenticators import BaseAuthenticator
24

  
23 25

  
24 26
from . import app_settings
25 27

  
26 28

  
27
class SAMLAuthenticator(object):
28
    id = 'saml'
29
class SAMLAuthenticator(BaseAuthenticator):
30

  
31
    def enabled(self, request=None):
32
        if not app_settings.enable:
33
            return False
34
        if not list(get_idps()):
35
            return False
36
        return self.is_condition_enabled(request)
29 37

  
30
    def enabled(self):
31
        return app_settings.enable and list(get_idps())
38
    @property
39
    def id(self):
40
        return 'saml'
32 41

  
33 42
    def name(self):
34 43
        return gettext_noop('SAML')
35 44

  
36 45
    def instances(self, request, *args, **kwargs):
46
        refs = self.get_enabled_references(request)
37 47
        for idx, idp in enumerate(get_idps()):
38
            yield(idx, idp)
48
            if str(idx) not in refs:
49
                yield(idx, idp)
39 50

  
40 51
    def login(self, request, *args, **kwargs):
41 52
        context = kwargs.pop('context', {})
tests/test_auth_oidc.py
130 130
    provider = OIDCProvider.objects.create(
131 131
        ou=get_default_ou(),
132 132
        name='OIDIDP',
133
        slug='oididp',
133 134
        issuer='http://server.example.com',
134 135
        authorization_endpoint='https://server.example.com/authorize',
135 136
        token_endpoint='https://server.example.com/token',
......
331 332
    assert response.pyquery('p#oidc-p-oidcidp-2')
332 333

  
333 334

  
335
def test_login_with_conditionnal_authenticators(oidc_provider, app, settings, caplog):
336

  
337
    OIDCProvider.objects.create(
338
        id=2,
339
        ou=get_default_ou(),
340
        name='My IDP',
341
        slug='my-idp',
342
        issuer='https://idp2.example.com/',
343
        authorization_endpoint='https://idp2.example.com/authorize',
344
        token_endpoint='https://idp2.example.com/token',
345
        end_session_endpoint='https://idp2.example.com/logout',
346
        userinfo_endpoint='https://idp*é.example.com/user_info',
347
        token_revocation_endpoint='https://idp2.example.com/revoke',
348
        max_auth_age=10,
349
        strategy=OIDCProvider.STRATEGY_CREATE,
350
        jwkset_json=None,
351
        idtoken_algo=OIDCProvider.ALGO_RSA,
352
        claims_parameter_supported=False
353
    )
354

  
355
    response = app.get('/login/')
356
    session = app.session
357
    session['login-hint'] = 'backoffice'
358
    session.save()
359

  
360
    settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'request|is_for_backoffice'}
361
    response = app.get('/login/')
362
    assert 'My IDP' in response
363
    assert 'OIDIDP' in response
364

  
365
    settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'not request|is_for_backoffice'}
366
    response = app.get('/login/')
367
    assert 'OIDIDP' in response
368
    assert 'My IDP' not in response
369

  
370
    settings.A2_AUTH_MODULES_CONDITIONS = {'oidc/my-idp': 'not request|is_for_backoffice',
371
                                           'oidc/oididp': 'not request|is_for_backoffice'}
372
    response = app.get('/login/')
373
    assert 'OIDIDP' not in response
374
    assert 'My IDP' not in response
334 375

  
335 376

  
336 377
def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url, login_callback_url, hooks):
tests/test_auth_saml.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17
import os
17 18
import logging
18 19

  
19 20
import pytest
......
23 24
from django.contrib.auth import get_user_model
24 25
from authentic2.models import Attribute
25 26

  
27
pytestmark = pytest.mark.django_db
28

  
26 29

  
27 30
def test_providers_on_login_page(db, app, settings):
28 31
    settings.A2_AUTH_SAML_ENABLE = True
......
124 127
    # on missing mandatory attribute, no user is created
125 128
    del saml_attributes['mail']
126 129
    assert adapter.lookup_user(idp, saml_attributes) is None
130

  
131

  
132
def test_login_with_conditionnal_authenticators(app, settings, caplog):
133

  
134
    settings.A2_AUTH_SAML_ENABLE = True
135
    settings.MELLON_IDENTITY_PROVIDERS = [
136
        {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')}
137
    ]
138
    response = app.get('/login/')
139
    assert 'login-saml-0' in response
140

  
141
    session = app.session
142
    session['login-hint'] = 'backoffice'
143
    session.save()
144

  
145
    settings.MELLON_IDENTITY_PROVIDERS.append(
146
        {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')}
147
    )
148

  
149
    settings.A2_AUTH_MODULES_CONDITIONS = {'saml/1': 'request|is_for_backoffice'}
150
    response = app.get('/login/')
151
    assert 'login-saml-0' in response
152
    assert 'login-saml-1' in response
153

  
154
    settings.A2_AUTH_MODULES_CONDITIONS = {'saml/1': 'not request|is_for_backoffice'}
155
    response = app.get('/login/')
156
    assert 'login-saml-0' in response
157
    assert 'login-saml-1' not in response
158

  
159
    settings.A2_AUTH_MODULES_CONDITIONS = {
160
        'saml/0': 'not request|is_for_backoffice',
161
        'saml/1': 'not request|is_for_backoffice'
162
    }
163
    response = app.get('/login/')
164
    assert 'login-saml-0' not in response
165
    assert 'login-saml-1' not in response
tests/test_login.py
21 21

  
22 22
from authentic2 import models
23 23

  
24
from utils import login
24
from utils import login, check_log
25 25

  
26 26

  
27 27
def test_login_inactive_user(db, app):
......
50 50
    assert '_auth_user_id' not in app.session
51 51

  
52 52

  
53
def test_login_with_conditionnal_enabled_authenticators(db, app, settings, caplog):
54
    settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'request|is_for_backoffice'}
55
    # open the page first time so session cookie can be set
56
    response = app.get('/login/')
57
    session = app.session
58
    session['login-hint'] = 'backoffice'
59
    session.save()
60
    response = app.get('/login/')
61
    assert 'name="login-password-submit"' in response
62

  
63
    settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'not request|is_for_backoffice'}
64
    response = app.get('/login/')
65
    # login form must not be displayed
66
    assert 'name="login-password-submit"' not in response
67
    assert len(caplog.records) == 0
68
    # set a condition with error
69
    with check_log(caplog, 'Invalid filter: \'is\''):
70
        settings.A2_AUTH_MODULES_CONDITIONS = {'password': 'not request|is'}
71
        response = app.get('/login/')
72
        assert 'name="login-password-submit"' not in response
73

  
74

  
53 75
def test_registration_url_on_login_page(db, app):
54 76
    response = app.get('/login/?next=/whatever')
55 77
    assert 'register/?next=/whatever"' in response
56
-