From f335a403c1a0acf05ffc7209c7e8981f23a6f70c Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Tue, 4 Oct 2022 10:44:59 +0200 Subject: [PATCH 7/8] views: implement a sessionless logout endpoint (#69740) To implement SAML single logout in authentic we need a logout endpoint which works event after the user session has been killed, to do that we store the needed information in Django signed token, and use it to initiate the logout request. Afterward the next_url is stored in short-lived session cookie instead of the session. --- mellon/views.py | 69 +++++++++++++++++++++++++++++++++++++++++-- tests/test_sso_slo.py | 24 +++++++++++++++ 2 files changed, 91 insertions(+), 2 deletions(-) diff --git a/mellon/views.py b/mellon/views.py index 13d7553..0437ba0 100644 --- a/mellon/views.py +++ b/mellon/views.py @@ -28,6 +28,7 @@ import requests from django.conf import settings from django.contrib import auth from django.contrib.auth import REDIRECT_FIELD_NAME, get_user_model +from django.core import signing from django.db import transaction from django.http import Http404, HttpResponse, HttpResponseForbidden, HttpResponseRedirect from django.shortcuts import render, resolve_url @@ -620,7 +621,9 @@ login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view())) class LogoutView(ProfileMixin, LogMixin, View): def get(self, request, *args, logout_next_url='/', **kwargs): - if 'SAMLRequest' in request.GET: + if 'token' in request.GET: + return self.sp_logout_token(request, token=request.GET['token'], logout_next_url=logout_next_url) + elif 'SAMLRequest' in request.GET: return self.idp_logout(request, request.META['QUERY_STRING'], 'redirect') elif 'SAMLResponse' in request.GET: return self.sp_logout_response(request, logout_next_url=logout_next_url) @@ -728,6 +731,9 @@ class LogoutView(ProfileMixin, LogMixin, View): else: return HttpResponseRedirect(logout.msgUrl) + def next_url_cookie_name(self, relaystate): + return f'MellonNextURL-{relaystate}' + def sp_logout_request(self, request, logout_next_url=None): '''Launch a logout request to the identity provider''' referer = request.headers.get('Referer') @@ -778,6 +784,9 @@ class LogoutView(ProfileMixin, LogMixin, View): '''Launch a logout request to the identity provider''' self.profile = logout = utils.create_logout(request) logout.msgRelayState = request.GET.get('RelayState') + cookie_name = self.next_url_cookie_name(logout.msgRelayState) + cookie_next_url = request.COOKIES.get(cookie_name) + next_url = self.get_next_url() or cookie_next_url or logout_next_url # the user shouldn't be logged anymore at this point but it may happen # that a concurrent SSO happened in the meantime, so we do another # logout to make sure. @@ -789,7 +798,63 @@ class LogoutView(ProfileMixin, LogMixin, View): self.log.warning('partial logout') except lasso.Error as e: self.log.warning('unable to process a logout response: %s', e) - return HttpResponseRedirect(self.get_next_url() or logout_next_url) + response = HttpResponseRedirect(next_url) + if cookie_name in request.COOKIES: + response.delete_cookie(cookie_name) + return response + + TOKEN_SALT = 'mellon-logout-token' + + def sp_logout_token(self, request, token, logout_next_url): + token_content = signing.loads(token, salt=self.TOKEN_SALT) + next_url = token_content['next_url'] or logout_next_url + session_index_pk = token_content['session_index_pk'] + session_indexes = models.SessionIndex.objects.filter(pk=session_index_pk) + if session_indexes: + session_dump = utils.make_session_dump(session_indexes) + logout = utils.create_logout(request) + logout.msgRelayState = str(uuid.uuid4()) + try: + logout.setSessionFromDump(session_dump) + logout.initRequest( + session_indexes[0].saml_identifier.issuer.entity_id, lasso.HTTP_METHOD_REDIRECT + ) + logout.buildRequestMsg() + except lasso.Error as e: + self.log.error('unable to initiate a logout request %r', e) + return HttpResponseRedirect(next_url) + except Exception: + self.log.exception('unable to initiate a logout request') + return HttpResponseRedirect(next_url) + else: + self.log.debug('sending LogoutRequest %r to URL %r', logout.request.dump(), logout.msgUrl) + response = HttpResponseRedirect(logout.msgUrl) + response.set_cookie( + self.next_url_cookie_name(logout.msgRelayState), + value=next_url, + max_age=600, + samesite='Lax', + ) + return response + return HttpResponseRedirect(next_url) + + @classmethod + def make_logout_token_url(cls, request, next_url=None): + issuer = request.session.get('mellon_session', {}).get('issuer') + if not issuer: + return None + session_indexes = models.SessionIndex.objects.filter( + saml_identifier__user=request.user, saml_identifier__issuer__entity_id=issuer + ).order_by('-id')[:1] + if not session_indexes: + return None + + token_content = { + 'next_url': next_url, + 'session_index_pk': session_indexes[0].pk, + } + token = signing.dumps(token_content, salt=cls.TOKEN_SALT) + return reverse('mellon_logout') + '?' + urlencode({'token': token}) logout = csrf_exempt(LogoutView.as_view()) diff --git a/tests/test_sso_slo.py b/tests/test_sso_slo.py index 08b202e..c40b800 100644 --- a/tests/test_sso_slo.py +++ b/tests/test_sso_slo.py @@ -864,3 +864,27 @@ def test_sso_slo_transient_name_identifier(db, app, idp, caplog, sp_settings): response = app.get(url) assert len(caplog.records) == 0, 'logout failed' assert response.location == '/' + + +def test_sso_slo_token(db, app, rf, idp, caplog, django_user_model): + from mellon.views import LogoutView + + caplog.set_level(logging.WARNING) + response = app.get('/login/') + url, body, relay_state = idp.process_authn_request_redirect(response['Location']) + response = app.post('/login/', params={'SAMLResponse': body, 'RelayState': relay_state}) + + request = rf.get('/whatever/') + request.session = app.session + request.user = django_user_model.objects.get() + token_logout_url = LogoutView.make_logout_token_url(request, next_url='/somepath/') + assert token_logout_url + app.session.flush() + assert '_auth_user_id' not in app.session + response = app.get(token_logout_url) + assert urlparse.urlparse(response['Location']).path == '/singleLogout' + url = idp.process_logout_request_redirect(response.location) + caplog.clear() + response = app.get(url) + assert len(caplog.records) == 0, 'logout failed' + assert response.location == '/somepath/' -- 2.37.2