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:
parent
218afde9cd
commit
f335a403c1
|
@ -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())
|
||||
|
|
|
@ -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/'
|
||||
|
|
Loading…
Reference in New Issue