Projet

Général

Profil

0001-misc-allow-authenticators-display-conditions-28215.patch

Serghei Mihai, 12 mars 2020 23:23

Télécharger (16,3 ko)

Voir les différences:

Subject: [PATCH] misc: allow authenticators display conditions (#28215)

 src/authentic2/authenticators.py           | 34 +++++++++--
 src/authentic2/utils/__init__.py           | 11 ++--
 src/authentic2/views.py                    |  9 ++-
 src/authentic2_auth_fc/authenticators.py   |  8 +--
 src/authentic2_auth_oidc/authenticators.py |  9 +--
 src/authentic2_auth_saml/authenticators.py |  5 +-
 tests/auth_fc/test_auth_fc.py              | 10 ++++
 tests/test_auth_oidc.py                    | 69 ++++++++++++++++++++++
 tests/test_auth_saml.py                    | 46 ++++++++++++++-
 tests/test_login.py                        | 18 +++++-
 10 files changed, 197 insertions(+), 22 deletions(-)
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
import logging
18

  
17 19
from django.db.models import Count
18 20
from django.shortcuts import render
19 21
from django.utils.translation import ugettext as _, ugettext_lazy
......
22 24
from authentic2.custom_user.models import User
23 25
from . import views, app_settings, utils, constants, models
24 26
from .forms import authentication as authentication_forms
27
from .utils.evaluate import evaluate_condition
28

  
29
logger = logging.getLogger(__name__)
30

  
31

  
32
class BaseAuthenticator(object):
25 33

  
34
    def __init__(self, show_condition=None, **kwargs):
35
        self.show_condition = show_condition
26 36

  
27
class LoginPasswordAuthenticator(object):
37
    def get_show_condition(self, instance_id=None):
38
        if isinstance(self.show_condition, dict):
39
            if instance_id and instance_id in self.show_condition:
40
                return self.show_condition[instance_id]
41
        else:
42
            return self.show_condition
43

  
44
    def shown(self, remote_addr=None, instance_id=None):
45
        show_condition = self.get_show_condition(instance_id)
46
        if not show_condition:
47
            return True
48
        try:
49
            return evaluate_condition(show_condition, {'id': instance_id, 'remote_addr': remote_addr})
50
        except Exception as e:
51
            logger.error(e)
52
            return False
53

  
54

  
55
class LoginPasswordAuthenticator(BaseAuthenticator):
56
    id = 'password'
28 57
    submit_name = 'login-password-submit'
29 58

  
30 59
    def enabled(self):
......
33 62
    def name(self):
34 63
        return ugettext_lazy('Password')
35 64

  
36
    def id(self):
37
        return 'password'
38

  
39 65
    def get_service_ous(self, request):
40 66
        service_slug = request.GET.get(constants.SERVICE_FIELD_NAME)
41 67
        if not service_slug:
src/authentic2/utils/__init__.py
149 149
    return list
150 150

  
151 151

  
152
def load_backend(path):
152
def load_backend(path, kwargs):
153 153
    '''Load an IdP backend by its module path'''
154 154
    i = path.rfind('.')
155 155
    module, attr = path[:i], path[i + 1:]
......
165 165
    except AttributeError:
166 166
        raise ImproperlyConfigured('Module "%s" does not define a "%s" idp backend'
167 167
                                   % (module, attr))
168
    return cls()
168
    backend_kwargs = {}
169
    if hasattr(cls, 'id'):
170
        backend_kwargs.update(kwargs.get(cls.id, {}))
171
    return cls(**backend_kwargs)
169 172

  
170 173

  
171 174
def get_backends(setting_name='IDP_BACKENDS'):
......
175 178
        kwargs = {}
176 179
        if not isinstance(backend_path, six.string_types):
177 180
            backend_path, kwargs = backend_path
178
        backend = load_backend(backend_path)
181
        kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
182
        backend = load_backend(backend_path, kwargs_settings)
179 183
        # If no enabled method is defined on the backend, backend enabled by default.
180 184
        if hasattr(backend, 'enabled') and not backend.enabled():
181 185
            continue
182
        kwargs_settings = getattr(app_settings, setting_name + '_KWARGS', {})
183 186
        if backend_path in kwargs_settings:
184 187
            kwargs.update(kwargs_settings[backend_path])
185 188
        # Clean id and name for legacy support
src/authentic2/views.py
324 324
            auth_blocks = []
325 325
            parameters = {'request': request,
326 326
                          'context': context}
327
            remote_addr = request.META.get('REMOTE_ADDR')
327 328
            # check if the authenticator has multiple instances
328 329
            if hasattr(authenticator, 'instances'):
329 330
                for instance_id, instance in authenticator.instances(**parameters):
330 331
                    parameters['instance'] = instance
331 332
                    parameters['instance_id'] = instance_id
333
                    if not authenticator.shown(remote_addr, instance_id):
334
                        continue
332 335
                    block = utils.get_authenticator_method(authenticator, 'login', parameters)
333 336
                    # update block id in order to separate instances
334 337
                    block['id'] = '%s_%s' % (block['id'], instance_id)
335 338
                    auth_blocks.append(block)
336 339
            else:
337
                auth_blocks.append(utils.get_authenticator_method(authenticator, 'login', parameters))
340
                if authenticator.shown(remote_addr):
341
                    auth_blocks.append(utils.get_authenticator_method(authenticator, 'login', parameters))
338 342
            # If a login frontend method returns an HttpResponse with a status code != 200
339 343
            # this response is returned.
340 344
            for block in auth_blocks:
......
344 348
                    block['is_hidden'] = False
345 349
                    blocks.append(block)
346 350
        # TODO: remove attribute below after cleaning up the templates
347
        blocks[-1]['is_hidden'] = False
351
        if blocks:
352
            blocks[-1]['is_hidden'] = False
348 353

  
349 354
    # Old frontends API
350 355
    for block in blocks:
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
class FcAuthenticator(BaseAuthenticator):
28
    id = 'fc'
29

  
27 30
    def enabled(self):
28 31
        return app_settings.enable
29 32

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

  
33
    def id(self):
34
        return 'fc'
35

  
36 36
    @property
37 37
    def popup(self):
38 38
        return app_settings.popup
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
23

  
24

  
25
class OIDCAuthenticator(BaseAuthenticator):
26
    id = 'oidc'
22 27

  
23 28

  
24
class OIDCAuthenticator(object):
25 29
    def enabled(self):
26 30
        return app_settings.ENABLE and utils.has_providers()
27 31

  
28 32
    def name(self):
29 33
        return gettext_noop('OpenIDConnect')
30 34

  
31
    def id(self):
32
        return 'oidc'
33

  
34 35
    def instances(self, request, *args, **kwargs):
35 36
        for p in utils.get_providers(shown=True):
36 37
            yield (p.slug, p)
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
23 24

  
24 25
from . import app_settings
25 26

  
26 27

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

  
30 31
    def enabled(self):
......
35 36

  
36 37
    def instances(self, request, *args, **kwargs):
37 38
        for idx, idp in enumerate(get_idps()):
38
            yield(idp.get('SLUG') or idx, idp)
39
            yield(idp.get('SLUG') or str(idx), idp)
39 40

  
40 41
    def login(self, request, *args, **kwargs):
41 42
        context = kwargs.pop('context', {})
tests/auth_fc/test_auth_fc.py
85 85
    return parsed['state']
86 86

  
87 87

  
88
def test_login_with_condition(app, fc_settings, settings):
89
    # open the page first time so session cookie can be set
90
    response = app.get('/login/')
91
    assert 'fc-button' in response
92

  
93
    settings.AUTH_FRONTENDS_KWARGS = {'fc': {'show_condition': 'remote_addr==\'0.0.0.0\''}}
94
    response = app.get('/login/')
95
    assert 'fc-button' not in response
96

  
97

  
88 98
@pytest.mark.parametrize('exp', [timestamp_from_datetime(now() + datetime.timedelta(seconds=1000)),
89 99
                                 timestamp_from_datetime(now() - datetime.timedelta(seconds=1000))])
90 100
def test_login_simple(app, fc_settings, caplog, hooks, exp):
tests/test_auth_oidc.py
154 154
    provider = OIDCProvider.objects.create(
155 155
        ou=get_default_ou(),
156 156
        name='OIDIDP',
157
        slug='oididp',
157 158
        issuer='http://server.example.com',
158 159
        authorization_endpoint='https://server.example.com/authorize',
159 160
        token_endpoint='https://server.example.com/token',
......
348 349
    assert response.pyquery('p#oidc-p-oidcidp-2')
349 350

  
350 351

  
352
def test_login_with_conditionnal_authenticators(oidc_provider, app, settings, caplog):
353

  
354
    oidc2_provider = OIDCProvider.objects.create(
355
        id=2,
356
        ou=get_default_ou(),
357
        name='My IDP',
358
        slug='myidp',
359
        issuer='https://idp2.example.com/',
360
        authorization_endpoint='https://idp2.example.com/authorize',
361
        token_endpoint='https://idp2.example.com/token',
362
        end_session_endpoint='https://idp2.example.com/logout',
363
        userinfo_endpoint='https://idp*é.example.com/user_info',
364
        token_revocation_endpoint='https://idp2.example.com/revoke',
365
        max_auth_age=10,
366
        strategy=OIDCProvider.STRATEGY_CREATE,
367
        jwkset_json=None,
368
        idtoken_algo=OIDCProvider.ALGO_RSA,
369
        claims_parameter_supported=False
370
    )
371
    response = app.get('/login/')
372
    assert 'My IDP' in response
373
    assert 'OIDIDP' in response
374

  
375
    settings.AUTH_FRONTENDS_KWARGS = {
376
        'oidc': {
377
            'show_condition': {
378
                'myidp': 'remote_addr==\'0.0.0.0\''
379
            }
380
        }
381
    }
382
    response = app.get('/login/')
383
    assert 'OIDIDP' in response
384
    assert 'My IDP' not in response
385

  
386
    settings.AUTH_FRONTENDS_KWARGS = {
387
        'oidc': {
388
            'show_condition': {
389
                'myid': 'remote_addr==\'0.0.0.0\'',
390
                'oididp': 'remote_addr==\'127.0.0.1\''
391
            }
392
        }
393
    }
394
    response = app.get('/login/')
395
    assert 'OIDIDP' in response
396
    assert 'My IDP' in response
397

  
398
    settings.AUTH_FRONTENDS_KWARGS = {
399
        'oidc': {
400
            'show_condition': {
401
                'myidp': 'remote_addr==\'0.0.0.0\'',
402
                'oididp': 'remote_addr==\'127.0.0.1\''
403
            }
404
        }
405
    }
406
    response = app.get('/login/')
407
    assert 'OIDIDP' in response
408
    assert 'My IDP' not in response
409

  
410
    settings.AUTH_FRONTENDS_KWARGS = {
411
        'oidc': {
412
            'show_condition': 'remote_addr==\'127.0.0.1\''
413
            }
414
    }
415
    response = app.get('/login/')
416
    assert 'OIDIDP' in response
417
    assert 'My IDP' in response
418

  
419

  
351 420
def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, hooks):
352 421
    OU = get_ou_model()
353 422
    cassis = OU.objects.create(name='Cassis', slug='cassis')
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

  
26

  
27 27
def test_providers_on_login_page(db, app, settings):
28 28
    settings.A2_AUTH_SAML_ENABLE = True
29 29
    PROVIDERS = [
......
133 133
    # on missing mandatory attribute, no user is created
134 134
    del saml_attributes['mail']
135 135
    assert adapter.lookup_user(idp, saml_attributes) is None
136

  
137

  
138
def test_login_with_conditionnal_authenticators(db, app, settings, caplog):
139

  
140
    settings.A2_AUTH_SAML_ENABLE = True
141
    settings.MELLON_IDENTITY_PROVIDERS = [
142
        {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')}
143
    ]
144
    response = app.get('/login/')
145
    assert 'login-saml-0' in response
146

  
147
    settings.AUTH_FRONTENDS_KWARGS = {
148
        'saml': {
149
            'show_condition': 'remote_addr==\'0.0.0.0\''
150
        }
151
    }
152
    response = app.get('/login/')
153
    assert 'login-saml-0' not in response
154

  
155
    settings.MELLON_IDENTITY_PROVIDERS.append(
156
        {"METADATA": os.path.join(os.path.dirname(__file__), 'metadata.xml')}
157
    )
158
    settings.AUTH_FRONTENDS_KWARGS = {
159
        'saml': {
160
            'show_condition': {
161
                '0': 'remote_addr==\'0.0.0.0\''
162
            }
163
        }
164
    }
165
    response = app.get('/login/')
166
    assert 'login-saml-0' not in response
167
    assert 'login-saml-1' in response
168

  
169
    settings.AUTH_FRONTENDS_KWARGS = {
170
        'saml': {
171
            'show_condition': {
172
                '0': 'remote_addr==\'0.0.0.0\'',
173
                '1': 'remote_addr==\'0.0.0.0\''
174
            }
175
        }
176
    }
177
    response = app.get('/login/')
178
    assert 'login-saml-0' not in response
179
    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
    response = app.get('/login/')
55
    assert 'name="login-password-submit"' in response
56

  
57
    settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': 'False'}}
58
    response = app.get('/login/')
59
    # login form must not be displayed
60
    assert 'name="login-password-submit"' not in response
61
    assert len(caplog.records) == 0
62
    # set a condition with error
63
    with check_log(caplog, 'name \'login_hint\' is not defined'):
64
        settings.AUTH_FRONTENDS_KWARGS = {'password': {'show_condition': '\'admin\' in login_hint'}}
65
        response = app.get('/login/')
66
        assert 'name="login-password-submit"' not in response
67

  
68

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