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.
This commit is contained in:
Benjamin Dauvergne 2022-10-04 10:44:59 +02:00
parent 218afde9cd
commit f335a403c1
2 changed files with 91 additions and 2 deletions

View File

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

View File

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