Projet

Général

Profil

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

Serghei Mihai, 19 décembre 2019 18:04

Télécharger (15,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           | 33 +++++++++++++++--
 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                    |  2 +-
 src/authentic2_auth_fc/authenticators.py   | 10 +++--
 src/authentic2_auth_oidc/authenticators.py | 25 +++++++++++--
 src/authentic2_auth_saml/authenticators.py | 17 +++++++--
 tests/test_auth_oidc.py                    | 43 ++++++++++++++++++++++
 tests/test_login.py                        | 24 +++++++++++-
 13 files changed, 165 insertions(+), 18 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

  
51
class LoginPasswordAuthenticator(BaseAuthenticator):
28 52
    submit_name = 'login-password-submit'
29 53

  
30
    def enabled(self):
31
        return app_settings.A2_AUTH_PASSWORD_ENABLE
54
    def enabled(self, request=None):
55
        if not app_settings.A2_AUTH_PASSWORD_ENABLE:
56
            return False
57
        return self.is_condition_enabled(request)
32 58

  
33 59
    def name(self):
34 60
        return ugettext_lazy('Password')
35 61

  
62
    @property
36 63
    def id(self):
37 64
        return 'password'
38 65

  
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

  
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
18 18
from django.shortcuts import render
19 19

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

  
23 25

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

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

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

  
37
    def filter_by_condition(self, request, providers):
38
        slugs = []
39
        if a2_settings.A2_AUTH_MODULES_CONDITIONS:
40
            for key, condition in a2_settings.A2_AUTH_MODULES_CONDITIONS.items():
41
                if '/' not in key:
42
                    continue
43
                id_, slug =  key.split('/', 1)
44
                if self.id != id_:
45
                    continue
46
                if not self.eval_condition(condition, request):
47
                    slugs.append(slug)
48
        if slugs:
49
            providers = providers.exclude(slug__in=slugs)
50
        return providers
51

  
34 52
    def instances(self, request, *args, **kwargs):
35
        for p in utils.get_providers(shown=True):
53
        providers = self.filter_by_condition(request, utils.get_providers(shown=True))
54
        for p in providers:
36 55
            yield (p.slug, p)
37 56

  
38 57
    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')
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
375

  
376

  
334 377

  
335 378

  
336 379
def test_sso(app, caplog, code, oidc_provider, oidc_provider_jwkset, login_url, login_callback_url, hooks):
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
-