0003-misc-proxy-passive-SSO-from-SAML2-services-to-OIDC-i.patch
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_canceled(app, idp): |
|
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 |
) |
|
1117 | ||
1118 | ||
1119 |
def test_sso_with_authenticator_passive_sso_authenticated(app, idp, user, monkeypatch): |
|
1120 |
scenario = Scenario( |
|
1121 |
app, |
|
1122 |
make_authn_request_kwargs={'is_passive': True}, |
|
1123 |
authn_request_success=False, |
|
1124 |
) |
|
1125 | ||
1126 |
authenticator = mock.Mock() |
|
1127 |
authenticator.show.return_value = True |
|
1128 | ||
1129 |
mock_passive_login = mock.Mock() |
|
1130 | ||
1131 |
def passive_login(request, block_id, next_url, passive=None): |
|
1132 |
mock_passive_login(request=request, block_id=block_id, next_url=next_url, passive=passive) |
|
1133 |
return HttpResponseRedirect('https://idp.example.com/?passive') |
|
1134 | ||
1135 |
authenticator.passive_login = passive_login |
|
1136 | ||
1137 |
with mock.patch('authentic2.utils.misc.get_authenticators', return_value=[authenticator]): |
|
1138 |
scenario.launch_authn_request() |
|
1139 | ||
1140 |
assert scenario.idp_response.location == 'https://idp.example.com/?passive' |
|
1141 |
assert mock_passive_login.call_args[1]['next_url'].startswith('/idp/saml2/continue?nonce=') |
|
1142 | ||
1143 |
# check NoPassive status code response after if conitnue is called and still no user is logged in |
|
1144 |
app.set_user(user.username) |
|
1145 |
with monkeypatch.context() as m: |
|
1146 |
m.setattr( |
|
1147 |
'django_webtest.backends.WebtestUserBackend.get_saml2_authn_context', |
|
1148 |
mock.Mock(return_value='webtest'), |
|
1149 |
raising=False, |
|
1150 |
) |
|
1151 |
response = app.get(mock_passive_login.call_args[1]['next_url']) |
|
1152 |
scenario.idp_response = response |
|
1153 |
scenario.handle_post_response() |
|
1154 |
assert scenario.sp.login.response.status.statusCode.value == 'urn:oasis:names:tc:SAML:2.0:status:Success' |
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 |
- |