idp_oidc: implement front-channel logout (fixes #22483)
This commit is contained in:
parent
20b829b1ee
commit
3bb3dd63c5
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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),
|
||||
),
|
||||
]
|
|
@ -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',
|
||||
|
|
|
@ -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>
|
|
@ -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
|
||||
|
|
|
@ -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)),
|
||||
|
|
|
@ -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):
|
||||
|
|
Loading…
Reference in New Issue