idp_oidc: implement front-channel logout (fixes #22483)

This commit is contained in:
Benjamin Dauvergne 2018-03-13 13:29:14 +01:00
parent 20b829b1ee
commit 3bb3dd63c5
8 changed files with 131 additions and 16 deletions

View File

@ -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

View File

@ -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)

View File

@ -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),
),
]

View File

@ -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',

View File

@ -0,0 +1,6 @@
{% load i18n %}
<div>{% blocktrans %}Sending logout to {{ name }}...{% endblocktrans %}
<iframe src="{{ url }}" marginwidth="0" marginheight="0" scrolling="no" style="border: none"
width="16" height="16" onload="setTimeout(function () { window.iframe_count -= 1; }, {{ iframe_timeout }})">
</iframe>
</div>

View File

@ -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

View File

@ -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)),

View File

@ -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):