Projet

Général

Profil

0003-misc-proxy-passive-SSO-from-SAML2-services-to-OIDC-i.patch

Benjamin Dauvergne, 13 décembre 2022 15:06

Télécharger (14,8 ko)

Voir les différences:

Subject: [PATCH 3/3] misc: proxy passive SSO from SAML2 services to OIDC idps
 (#27135)

Behaviour of the SAML2 when receiving a Passive AuthnRequest and not
user is logged is modified. Before an immediate response with StatusCode
no-passive was returned. Now if one authenticator with the method
passive_login is found, the request is transferred to this authentication
source.
 src/authentic2/idp/saml/saml2_endpoints.py | 29 +++++++--
 src/authentic2/views.py                    | 31 +++++++++
 src/authentic2_auth_oidc/models.py         |  5 ++
 src/authentic2_auth_oidc/views.py          |  7 +-
 tests/test_auth_oidc.py                    | 76 ++++++++++++++++++++++
 tests/test_idp_saml2.py                    | 59 +++++++++++++++++
 tests/test_views.py                        | 23 +++++++
 7 files changed, 224 insertions(+), 6 deletions(-)
src/authentic2/idp/saml/saml2_endpoints.py
114 114
from authentic2.utils.misc import login_require, make_url
115 115
from authentic2.utils.service import set_service
116 116
from authentic2.utils.view_decorators import check_view_restriction, enable_view_restriction
117
from authentic2.views import passive_login
117 118

  
118 119
from . import app_settings
119 120

  
......
629 630
    return sso_after_process_request(request, login, nid_format=nid_format)
630 631

  
631 632

  
633
def make_continue_url(login, nid_format):
634
    nonce = login.request.id or get_nonce()
635
    save_key_values(nonce, force_str(login.dump()), False, nid_format)
636
    return make_url(continue_sso, params={NONCE_FIELD_NAME: nonce})
637

  
638

  
639
def saml_passive_login(request, login, nid_format):
640
    return passive_login(
641
        request,
642
        next_url=make_continue_url(login, nid_format),
643
        login_hint=get_login_hints_extension(login),
644
    )
645

  
646

  
632 647
def need_login(request, login, nid_format):
633 648
    """Redirect to the login page with a nonce parameter to verify later that
634 649
    the login form was submitted
635 650
    """
636 651
    nonce = login.request.id or get_nonce()
637
    save_key_values(nonce, force_str(login.dump()), False, nid_format)
638
    next_url = make_url(continue_sso, params={NONCE_FIELD_NAME: nonce})
639
    logger.debug('redirect to login page with next url %s', next_url)
640 652
    return login_require(
641 653
        request,
642
        next_url=next_url,
654
        next_url=make_continue_url(login, nid_format),
643 655
        params={NONCE_FIELD_NAME: nonce},
644 656
        login_hint=get_login_hints_extension(login),
645 657
    )
......
722 734
        consent_obtained=consent_obtained,
723 735
        consent_attribute_answer=consent_attribute_answer,
724 736
        nid_format=nid_format,
737
        passive_login=False,
725 738
    )
726 739

  
727 740

  
......
769 782
    user=None,
770 783
    nid_format='transient',
771 784
    return_profile=False,
785
    passive_login=True,
772 786
):
773 787
    """Common path for sso and idp_initiated_sso.
774 788

  
......
802 816
        return need_login(request, login, nid_format)
803 817

  
804 818
    # No user is authenticated and passive is True, deny request
805
    if passive and user.is_anonymous:
819
    if passive and not user.is_authenticated:
820
        # passive_login is false if caller is continue_sso (after a failed passive login)
821
        if passive_login:
822
            passive_login_response = saml_passive_login(request, login, nid_format)
823
            if passive_login_response is not None:
824
                return passive_login_response
806 825
        logger.debug('no user connected and passive request, returning NoPassive')
807 826
        set_saml2_response_responder_status_code(login.response, lasso.SAML2_STATUS_CODE_NO_PASSIVE)
808 827
        return finish_sso(request, login)
src/authentic2/views.py
331 331
email_change_verify = EmailChangeVerifyView.as_view()
332 332

  
333 333

  
334
def passive_login(request, *, next_url, login_hint=None):
335
    '''View to use in IdP backends to implement passive login toward IdPs'''
336
    service = get_service(request)
337
    authenticators = utils_misc.get_authenticators()
338

  
339
    login_hint = login_hint or {}
340
    show_ctx = make_condition_context(request=request, login_hint=login_hint)
341
    if service:
342
        show_ctx['service_ou_slug'] = service.ou and service.ou.slug
343
        show_ctx['service_slug'] = service.slug
344
        show_ctx['service'] = service
345
    else:
346
        show_ctx['service_ou_slug'] = ''
347
        show_ctx['service_slug'] = ''
348
        show_ctx['service'] = None
349
    visible_authenticators = [
350
        authenticator
351
        for authenticator in authenticators
352
        if (authenticator.shown(ctx=show_ctx) and getattr(authenticator, 'passive_login', None))
353
    ]
354

  
355
    if len(visible_authenticators) != 1:
356
        return None
357
    unique_authenticator = visible_authenticators[0]
358
    return unique_authenticator.passive_login(
359
        request,
360
        block_id=unique_authenticator.get_identifier(),
361
        next_url=next_url,
362
    )
363

  
364

  
334 365
@csrf_exempt
335 366
@ensure_csrf_cookie
336 367
@never_cache
src/authentic2_auth_oidc/models.py
237 237

  
238 238
        return views.oidc_login(request, pk=self.pk, next_url=next_url)
239 239

  
240
    def passive_login(self, request, block_id, next_url):
241
        from . import views
242

  
243
        return views.oidc_login(request, pk=self.pk, next_url=next_url, passive=True)
244

  
240 245
    def login(self, request, *args, **kwargs):
241 246
        context = kwargs.get('context', {}).copy()
242 247
        context['provider'] = self
src/authentic2_auth_oidc/views.py
42 42
    return hashlib.sha256(state.encode() + settings.SECRET_KEY.encode()).hexdigest()
43 43

  
44 44

  
45
def oidc_login(request, pk, next_url=None, *args, **kwargs):
45
def oidc_login(request, pk, next_url=None, passive=None, *args, **kwargs):
46 46
    provider = get_provider(pk)
47 47
    scopes = set(provider.scopes.split()) | {'openid'}
48 48
    state_id = str(uuid.uuid4())
......
58 58
    }
59 59
    if next_url:
60 60
        state_content['next'] = next_url
61
    if passive is True or passive is False:
62
        if passive:
63
            prompt.add('none')
64
        else:
65
            prompt.add('login')
61 66
    params = {
62 67
        'client_id': provider.client_id,
63 68
        'scope': ' '.join(scopes),
tests/test_auth_oidc.py
21 21
import re
22 22
import time
23 23
import urllib.parse
24
from unittest import mock
24 25

  
25 26
import pytest
26 27
from django.contrib.auth import get_user_model
......
45 46
from authentic2_auth_oidc.backends import OIDCBackend
46 47
from authentic2_auth_oidc.models import OIDCAccount, OIDCClaimMapping, OIDCProvider
47 48
from authentic2_auth_oidc.utils import IDToken, IDTokenError, parse_id_token, register_issuer
49
from authentic2_auth_oidc.views import oidc_login
48 50

  
49 51
from . import utils
50 52

  
......
1394 1396
    warnings = response.pyquery('.warning')
1395 1397
    assert len(warnings) == 1
1396 1398
    assert 'Your email is already linked' in warnings.text()
1399

  
1400

  
1401
@mock.patch('authentic2_auth_oidc.views.get_provider')
1402
def test_oidc_login(get_provider, rf):
1403
    AUTHORIZE_URL = 'https://op.example.com/authorize'
1404
    SCOPES = {'profile'}
1405

  
1406
    provider = OIDCProvider(
1407
        pk=1, client_id='1234', authorization_endpoint=AUTHORIZE_URL, scopes=' '.join(SCOPES)
1408
    )
1409
    get_provider.return_value = provider
1410

  
1411
    url = oidc_login(rf.get('/', secure=True), 1, next_url='/idp/x/').url
1412
    assert url
1413
    prefix, query = url.split('?', 1)
1414
    assert prefix == AUTHORIZE_URL
1415
    qs = dict(urllib.parse.parse_qsl(query))
1416
    assert qs['client_id'] == '1234'
1417
    assert qs['nonce']
1418
    assert qs['state']
1419
    assert qs['redirect_uri'] == 'https://testserver/accounts/oidc/callback/'
1420
    assert qs['ui_locales'] == 'en'
1421
    assert set(qs['scope'].split()) == {'profile', 'openid'}
1422
    assert 'prompt' not in qs
1423

  
1424
    # passive
1425
    url = oidc_login(rf.get('/', secure=True), 1, next_url='/idp/x/', passive=True).url
1426
    prefix, query = url.split('?', 1)
1427
    qs = dict(urllib.parse.parse_qsl(query))
1428
    assert qs['prompt'] == 'none'
1429

  
1430
    # not passive
1431
    url = oidc_login(rf.get('/', secure=True), 1, next_url='/idp/x/', passive=False).url
1432
    prefix, query = url.split('?', 1)
1433
    qs = dict(urllib.parse.parse_qsl(query))
1434
    assert qs['prompt'] == 'login'
1435

  
1436

  
1437
@mock.patch('authentic2_auth_oidc.views.get_provider')
1438
def test_autorun(get_provider, rf):
1439
    AUTHORIZE_URL = 'https://op.example.com/authorize'
1440
    SCOPES = {'profile'}
1441

  
1442
    provider = OIDCProvider(
1443
        pk=1, client_id='1234', authorization_endpoint=AUTHORIZE_URL, scopes=' '.join(SCOPES)
1444
    )
1445
    get_provider.return_value = provider
1446
    req = rf.get('/?next=/idp/x/')
1447
    req.user = mock.Mock()
1448
    req.user.is_authenticated = False
1449

  
1450
    url = provider.autorun(req, block_id=1, next_url='/').url
1451
    _, query = url.split('?', 1)
1452
    qs = dict(urllib.parse.parse_qsl(query))
1453
    assert 'prompt' not in qs
1454

  
1455

  
1456
@mock.patch('authentic2_auth_oidc.views.get_provider')
1457
def test_passive_login(get_provider, rf):
1458
    AUTHORIZE_URL = 'https://op.example.com/authorize'
1459
    SCOPES = {'profile'}
1460

  
1461
    provider = OIDCProvider(
1462
        pk=1, client_id='1234', authorization_endpoint=AUTHORIZE_URL, scopes=' '.join(SCOPES)
1463
    )
1464
    get_provider.return_value = provider
1465
    req = rf.get('/?next=/idp/x/')
1466
    req.user = mock.Mock()
1467
    req.user.is_authenticated = False
1468

  
1469
    url = provider.passive_login(req, block_id=1, next_url='/').url
1470
    _, query = url.split('?', 1)
1471
    qs = dict(urllib.parse.parse_qsl(query))
1472
    assert qs['prompt'] == 'none'
tests/test_idp_saml2.py
27 27
import pytest
28 28
from django.contrib.auth import REDIRECT_FIELD_NAME
29 29
from django.core.files import File
30
from django.http import HttpResponseRedirect
30 31
from django.template import Context, Template
31 32
from django.urls import reverse
32 33
from django.utils.encoding import force_bytes, force_str
......
1055 1056
    scenario.launch_authn_request()
1056 1057
    scenario.login(user=user)
1057 1058
    assert scenario.idp_response.location.startswith('/accounts/edit/required/?')
1059

  
1060

  
1061
def test_sso_is_passive(app, idp, user, cgu_attribute, caplog):
1062
    scenario = Scenario(
1063
        app,
1064
        make_authn_request_kwargs={'is_passive': True},
1065
        authn_request_success=False,
1066
    )
1067
    scenario.launch_authn_request()
1068

  
1069
    with pytest.raises(lasso.ProfileStatusNotSuccessError):
1070
        scenario.handle_post_response()
1071

  
1072
    assert (
1073
        scenario.sp.login.response.status.statusCode.value == 'urn:oasis:names:tc:SAML:2.0:status:Responder'
1074
    )
1075
    assert (
1076
        scenario.sp.login.response.status.statusCode.statusCode.value
1077
        == 'urn:oasis:names:tc:SAML:2.0:status:NoPassive'
1078
    )
1079

  
1080

  
1081
def test_sso_with_authenticator_passive_sso(app, idp, user, cgu_attribute, caplog):
1082
    scenario = Scenario(
1083
        app,
1084
        make_authn_request_kwargs={'is_passive': True},
1085
        authn_request_success=False,
1086
    )
1087

  
1088
    authenticator = mock.Mock()
1089
    authenticator.show.return_value = True
1090

  
1091
    mock_passive_login = mock.Mock()
1092

  
1093
    def passive_login(request, block_id, next_url, passive=None):
1094
        mock_passive_login(request=request, block_id=block_id, next_url=next_url, passive=passive)
1095
        return HttpResponseRedirect('https://idp.example.com/?passive')
1096

  
1097
    authenticator.passive_login = passive_login
1098

  
1099
    with mock.patch('authentic2.utils.misc.get_authenticators', return_value=[authenticator]):
1100
        scenario.launch_authn_request()
1101

  
1102
    assert scenario.idp_response.location == 'https://idp.example.com/?passive'
1103
    assert mock_passive_login.call_args[1]['next_url'].startswith('/idp/saml2/continue?nonce=')
1104

  
1105
    # check NoPassive status code response after if conitnue is called and still no user is logged in
1106
    response = app.get(mock_passive_login.call_args[1]['next_url'])
1107
    scenario.idp_response = response
1108
    with pytest.raises(lasso.ProfileStatusNotSuccessError):
1109
        scenario.handle_post_response()
1110
    assert (
1111
        scenario.sp.login.response.status.statusCode.value == 'urn:oasis:names:tc:SAML:2.0:status:Responder'
1112
    )
1113
    assert (
1114
        scenario.sp.login.response.status.statusCode.statusCode.value
1115
        == 'urn:oasis:names:tc:SAML:2.0:status:NoPassive'
1116
    )
tests/test_views.py
26 26
from authentic2.custom_user.models import DeletedUser, User
27 27
from authentic2.forms.passwords import PasswordChangeForm, SetPasswordForm
28 28
from authentic2.models import Attribute
29
from authentic2.views import passive_login
29 30

  
30 31
from .utils import assert_event, get_link_from_mail, login, logout
31 32

  
......
419 420
    assert (
420 421
        app.get('/accounts/password/reset/confirm/abcd1234/').location == '/password/reset/confirm/abcd1234/'
421 422
    )
423

  
424

  
425
def test_passive_login(rf):
426
    from django.contrib.sessions.middleware import SessionMiddleware
427

  
428
    req = rf.get('/')
429
    SessionMiddleware(lambda x: None).process_request(req)
430
    assert passive_login(req, next_url='/', login_hint={'pop'}) is None
431

  
432
    authenticator1 = mock.Mock()
433
    authenticator1.show.return_value = True
434
    authenticator1.passive_login.return_value = 'response'
435
    authenticator2 = mock.Mock()
436
    authenticator2.show.return_value = True
437
    authenticator2.passive_login.return_value = 'response'
438

  
439
    with mock.patch(
440
        'authentic2.utils.misc.get_authenticators', return_value=[authenticator1, authenticator2]
441
    ):
442
        assert passive_login(req, next_url='/', login_hint={'pop'}) is None
443
        authenticator2.passive_login = None
444
        assert passive_login(req, next_url='/', login_hint={'pop'}) == 'response'
422
-