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/'