From 3bb3dd63c52cb6102eff29df3f4bbdc0dd3d5cda Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Tue, 13 Mar 2018 13:29:14 +0100 Subject: [PATCH] idp_oidc: implement front-channel logout (fixes #22483) --- src/authentic2_idp_oidc/__init__.py | 23 +++++++++++-- src/authentic2_idp_oidc/app_settings.py | 4 +++ .../migrations/0009_auto_20180313_1156.py | 24 ++++++++++++++ src/authentic2_idp_oidc/models.py | 7 ++++ .../authentic2_idp_oidc/logout_fragment.html | 6 ++++ src/authentic2_idp_oidc/utils.py | 38 ++++++++++++++++++++++ src/authentic2_idp_oidc/views.py | 10 ++++-- tests/test_idp_oidc.py | 35 +++++++++++++------- 8 files changed, 131 insertions(+), 16 deletions(-) create mode 100644 src/authentic2_idp_oidc/migrations/0009_auto_20180313_1156.py create mode 100644 src/authentic2_idp_oidc/templates/authentic2_idp_oidc/logout_fragment.html diff --git a/src/authentic2_idp_oidc/__init__.py b/src/authentic2_idp_oidc/__init__.py index 17dec395..1f04c368 100644 --- a/src/authentic2_idp_oidc/__init__.py +++ b/src/authentic2_idp_oidc/__init__.py @@ -1,3 +1,4 @@ +from django.template.loader import render_to_string from django.utils.translation import ugettext_lazy as _ default_app_config = 'authentic2_idp_oidc.apps.AppConfig' @@ -11,8 +12,26 @@ class Plugin(object): def get_apps(self): return [__name__] - def redirect_logout_list(self, request, next=None): - return [] + def logout_list(self, request): + from .utils import get_oidc_sessions + from . import app_settings + + fragments = [] + + oidc_sessions = get_oidc_sessions(request) + for key, value in oidc_sessions.iteritems(): + if 'frontchannel_logout_uri' not in value: + continue + ctx = { + 'url': value['frontchannel_logout_uri'], + 'name': value['name'], + 'iframe_timeout': value.get('frontchannel_timeout') or app_settings.DEFAULT_FRONTCHANNEL_TIMEOUT, + } + fragments.append( + render_to_string( + 'authentic2_idp_oidc/logout_fragment.html', + ctx)) + return fragments def get_admin_modules(self): from admin_tools.dashboard import modules diff --git a/src/authentic2_idp_oidc/app_settings.py b/src/authentic2_idp_oidc/app_settings.py index f25598ff..8f90c8b4 100644 --- a/src/authentic2_idp_oidc/app_settings.py +++ b/src/authentic2_idp_oidc/app_settings.py @@ -26,6 +26,10 @@ class AppSettings(object): def SCOPES(self): return self._setting('SCOPES', []) + @property + def DEFAULT_FRONTCHANNEL_TIMEOUT(self): + return self._setting('DEFAULT_FRONTCHANNEL_TIMEOUT', 10000) + @property def IDTOKEN_DURATION(self): return self._setting('IDTOKEN_DURATION', 30) diff --git a/src/authentic2_idp_oidc/migrations/0009_auto_20180313_1156.py b/src/authentic2_idp_oidc/migrations/0009_auto_20180313_1156.py new file mode 100644 index 00000000..532d7a6a --- /dev/null +++ b/src/authentic2_idp_oidc/migrations/0009_auto_20180313_1156.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentic2_idp_oidc', '0008_oidcclient_idtoken_duration'), + ] + + operations = [ + migrations.AddField( + model_name='oidcclient', + name='frontchannel_logout_uri', + field=models.URLField(verbose_name='frontchannel logout URI', blank=True), + ), + migrations.AddField( + model_name='oidcclient', + name='frontchannel_timeout', + field=models.PositiveIntegerField(null=True, verbose_name='frontchannel timeout', blank=True), + ), + ] diff --git a/src/authentic2_idp_oidc/models.py b/src/authentic2_idp_oidc/models.py index 8bf5ec74..3befea1f 100644 --- a/src/authentic2_idp_oidc/models.py +++ b/src/authentic2_idp_oidc/models.py @@ -115,6 +115,13 @@ class OIDCClient(Service): has_api_access = models.BooleanField( verbose_name=_('has API access'), default=False) + frontchannel_logout_uri = models.URLField( + verbose_name=_('frontchannel logout URI'), + blank=True) + frontchannel_timeout = models.PositiveIntegerField( + verbose_name=_('frontchannel timeout'), + null=True, + blank=True) authorizations = GenericRelation('OIDCAuthorization', content_type_field='client_ct', diff --git a/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/logout_fragment.html b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/logout_fragment.html new file mode 100644 index 00000000..b90cbcdc --- /dev/null +++ b/src/authentic2_idp_oidc/templates/authentic2_idp_oidc/logout_fragment.html @@ -0,0 +1,6 @@ +{% load i18n %} +
{% blocktrans %}Sending logout to {{ name }}...{% endblocktrans %} + +
diff --git a/src/authentic2_idp_oidc/utils.py b/src/authentic2_idp_oidc/utils.py index 0f4844d3..71fc3c8a 100644 --- a/src/authentic2_idp_oidc/utils.py +++ b/src/authentic2_idp_oidc/utils.py @@ -9,6 +9,7 @@ from jwcrypto.jwt import JWT from django.core.exceptions import ImproperlyConfigured from django.conf import settings +from django.utils.encoding import smart_bytes from authentic2 import hooks, crypto @@ -173,3 +174,40 @@ def create_user_info(client, user, scope_set, id_token=False): }) hooks.call_hooks('idp_oidc_modify_user_info', client, user, scope_set, user_info) return user_info + + +def get_issuer(request): + return request.build_absolute_uri('/') + + +def get_session_id(request, client): + '''Derive an OIDC Session Id from the real session identifier, the sector + identifier of the RP and the secret key of the Django instance''' + session_key = smart_bytes(request.session.session_key) + sector_identifier = smart_bytes(get_sector_identifier(client)) + secret_key = smart_bytes(settings.SECRET_KEY) + return hashlib.md5(session_key + sector_identifier + secret_key).hexdigest() + + +def get_oidc_sessions(request): + return request.session.get('oidc_sessions', {}) + + +def add_oidc_session(request, client): + oidc_sessions = request.session.setdefault('oidc_sessions', {}) + if not client.frontchannel_logout_uri: + return + uri = client.frontchannel_logout_uri + oidc_session = { + 'frontchannel_logout_uri': uri, + 'frontchannel_timeout': client.frontchannel_timeout, + 'name': client.name, + 'sid': get_session_id(request, client), + 'iss': get_issuer(request), + } + if oidc_sessions.get(uri) == oidc_session: + # already present + return + oidc_sessions[uri] = oidc_session + # force session save + request.session.modified = True diff --git a/src/authentic2_idp_oidc/views.py b/src/authentic2_idp_oidc/views.py index 935e71b4..5070cdcd 100644 --- a/src/authentic2_idp_oidc/views.py +++ b/src/authentic2_idp_oidc/views.py @@ -27,7 +27,7 @@ from . import app_settings, models, utils @setting_enabled('ENABLE', settings=app_settings) def openid_configuration(request, *args, **kwargs): metadata = { - 'issuer': request.build_absolute_uri('/'), + 'issuer': utils.get_issuer(request), 'authorization_endpoint': request.build_absolute_uri(reverse('oidc-authorize')), 'token_endpoint': request.build_absolute_uri(reverse('oidc-token')), 'jwks_uri': request.build_absolute_uri(reverse('oidc-certs')), @@ -41,6 +41,8 @@ def openid_configuration(request, *args, **kwargs): 'RS256', 'HS256', ], 'userinfo_endpoint': request.build_absolute_uri(reverse('oidc-user-info')), + 'frontchannel_logout_supported': True, + 'frontchannel_logout_session_supported': True, } return HttpResponse(json.dumps(metadata), content_type='application/json') @@ -279,12 +281,13 @@ def authorize(request, *args, **kwargs): acr = '1' id_token = utils.create_user_info(client, request.user, scopes, id_token=True) id_token.update({ - 'iss': request.build_absolute_uri('/'), + 'iss': utils.get_issuer(request), 'aud': client.client_id, 'exp': timestamp_from_datetime(start + idtoken_duration(client)), 'iat': timestamp_from_datetime(start), 'auth_time': last_auth['when'], 'acr': acr, + 'sid': utils.get_session_id(request, client), }) if nonce is not None: id_token['nonce'] = nonce @@ -302,6 +305,7 @@ def authorize(request, *args, **kwargs): # query is transfered through the hashtag response = redirect(request, redirect_uri + '#%s' % urlencode(params), resolve=False) hooks.call_hooks('event', name='sso-success', idp='oidc', service=client, user=request.user) + utils.add_oidc_session(request, client) return response @@ -384,7 +388,7 @@ def token(request, *args, **kwargs): # prefill id_token with user info id_token = utils.create_user_info(client, oidc_code.user, oidc_code.scope_set(), id_token=True) id_token.update({ - 'iss': request.build_absolute_uri('/'), + 'iss': utils.get_issuer(request), 'sub': utils.make_sub(client, oidc_code.user), 'aud': client.client_id, 'exp': timestamp_from_datetime(start + idtoken_duration(client)), diff --git a/tests/test_idp_oidc.py b/tests/test_idp_oidc.py index 8e8d90ad..0e4f7dac 100644 --- a/tests/test_idp_oidc.py +++ b/tests/test_idp_oidc.py @@ -47,17 +47,20 @@ def test_get_jwkset(oidc_settings): from authentic2_idp_oidc.utils import get_jwkset get_jwkset() + OIDC_CLIENT_PARAMS = [ { 'authorization_flow': OIDCClient.FLOW_IMPLICIT, }, - {}, + { + 'post_logout_redirect_uris': 'https://example.com/', + }, { 'identifier_policy': OIDCClient.POLICY_UUID, + 'post_logout_redirect_uris': 'https://example.com/', }, { 'identifier_policy': OIDCClient.POLICY_EMAIL, - 'post_logout_redirect_uris': '', }, { 'idtoken_algo': OIDCClient.ALGO_HMAC, @@ -71,6 +74,14 @@ OIDC_CLIENT_PARAMS = [ { 'authorization_flow': OIDCClient.FLOW_IMPLICIT, 'idtoken_duration': datetime.timedelta(hours=1), + 'post_logout_redirect_uris': 'https://example.com/', + }, + { + 'frontchannel_logout_uri': 'https://example.com/southpark/logout/', + }, + { + 'frontchannel_logout_uri': 'https://example.com/southpark/logout/', + 'frontchannel_timeout': 3000, }, ] @@ -85,7 +96,6 @@ def oidc_client(request, superuser, app): response.form.set('ou', get_default_ou().pk) response.form.set('unauthorized_url', 'https://example.com/southpark/') response.form.set('redirect_uris', 'https://example.com/callback') - response.form.set('post_logout_redirect_uris', 'https://example.com/') for key, value in request.param.iteritems(): response.form.set(key, value) response = response.form.submit().follow() @@ -233,23 +243,26 @@ def test_authorization_code_sso(login_first, oidc_settings, oidc_client, simple_ assert response.json['email_verified'] is True # Now logout - params = {} if oidc_client.post_logout_redirect_uris: params = { 'post_logout_redirect_uri': oidc_client.post_logout_redirect_uris, 'state': 'xyz', } - logout_url = make_url('oidc-logout', params=params) - response = app.get(logout_url) - if oidc_client.post_logout_redirect_uris: + logout_url = make_url('oidc-logout', params=params) + response = app.get(logout_url) assert 'You have been logged out' in response.content assert 'https://example.com/?state=xyz' in response.content assert '_auth_user_id' not in app.session else: - response = response.maybe_follow() - assert 'You have been logged out' in response.content - assert response.request.environ['HTTP_HOST'] == 'testserver' - assert response.request.environ['PATH_INFO'] == '/login/' + response = app.get(make_url('account_management')) + response = response.click('Logout') + if oidc_client.frontchannel_logout_uri: + iframes = response.pyquery('iframe[src="https://example.com/southpark/logout/"]') + assert iframes + if oidc_client.frontchannel_timeout: + assert iframes.attr('onload').endswith(', %d)' % oidc_client.frontchannel_timeout) + else: + assert iframes.attr('onload').endswith(', 10000)') def assert_oidc_error(response, error, error_description=None, fragment=False): -- 2.14.2