misc: support asynchronous logout (#41949)

It means that will lookup for other Django sessions linked to the
received logout request; logout request can specify session indexes or
ask for logout of all sessions of the user targeted by the NameID.
This commit is contained in:
Benjamin Dauvergne 2020-04-24 12:52:34 +02:00
parent 2c6a051b4a
commit 65cbdcefc3
4 changed files with 246 additions and 41 deletions

View File

@ -1,10 +1,12 @@
<ns0:Session xmlns:ns0="http://www.entrouvert.org/namespaces/lasso/0.0" xmlns:ns1="urn:oasis:names:tc:SAML:2.0:assertion" Version="2">
{% for session_info in session_infos %}
<ns0:NidAndSessionIndex AssertionID=""
ProviderID="{{ entity_id }}"
SessionIndex="{{ session_index }}">
<ns1:NameID Format="{{ name_id_format }}"
{% if name_id_name_qualifier %}NameQualifier="{{ name_id_name_qualifier }}"{% endif %}
{% if name_id_sp_name_qualifier %}SPNameQualifier="{{ name_id_sp_name_qualifier }}"{% endif %}
>{{ name_id_content }}</ns1:NameID>
ProviderID="{{ session_info.entity_id }}"
SessionIndex="{{ session_info.session_index }}">
<ns1:NameID Format="{{ session_info.name_id_format }}"
{% if session_info.name_id_name_qualifier %}NameQualifier="{{ session_info.name_id_name_qualifier }}"{% endif %}
{% if session_info.name_id_sp_name_qualifier %}SPNameQualifier="{{ session_info.name_id_sp_name_qualifier }}"{% endif %}
>{{ session_info.name_id_content }}</ns1:NameID>
</ns0:NidAndSessionIndex>
{% endfor %}
</ns0:Session>

View File

@ -22,10 +22,10 @@ from functools import wraps
import isodate
from xml.parsers import expat
import django
from django.contrib import auth
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.encoding import force_text
from django.utils.timezone import make_aware, now, make_naive, is_aware, get_default_timezone
from django.conf import settings
from django.utils.six.moves.urllib.parse import urlparse
@ -33,6 +33,7 @@ import lasso
from . import app_settings
logger = logging.getLogger(__name__)
def create_metadata(request):
entity_id = reverse('mellon_metadata')
@ -64,7 +65,6 @@ def create_metadata(request):
def create_server(request):
logger = logging.getLogger(__name__)
root = request.build_absolute_uri('/')
cache = getattr(settings, '_MELLON_SERVER_CACHE', {})
if root not in cache:
@ -203,29 +203,31 @@ def get_setting(idp, name, default=None):
return idp.get(name) or getattr(app_settings, name, default)
def make_session_dump(lasso_name_id, indexes):
session_infos = []
name_id = force_text(lasso_name_id.content)
name_id_format = force_text(lasso_name_id.format)
name_qualifier = lasso_name_id.nameQualifier and force_text(lasso_name_id.nameQualifier)
sp_name_qualifier = lasso_name_id.spNameQualifier and force_text(lasso_name_id.spNameQualifier)
for index in indexes:
issuer = index.saml_identifier.issuer
session_infos.append({
'entity_id': issuer,
'session_index': index.session_index,
'name_id_content': name_id,
'name_id_format': name_id_format,
'name_id_name_qualifier': name_qualifier,
'name_id_sp_name_qualifier': sp_name_qualifier,
})
session_dump = render_to_string('mellon/session_dump.xml', {'session_infos': session_infos})
return session_dump
def create_logout(request):
logger = logging.getLogger(__name__)
server = create_server(request)
mellon_session = request.session.get('mellon_session', {})
entity_id = mellon_session.get('issuer')
session_index = mellon_session.get('session_index')
name_id_format = mellon_session.get('name_id_format')
name_id_content = mellon_session.get('name_id_content')
name_id_name_qualifier = mellon_session.get('name_id_name_qualifier')
name_id_sp_name_qualifier = mellon_session.get('name_id_sp_name_qualifier')
session_dump = render_to_string('mellon/session_dump.xml', {
'entity_id': entity_id,
'session_index': session_index,
'name_id_format': name_id_format,
'name_id_content': name_id_content,
'name_id_name_qualifier': name_id_name_qualifier,
'name_id_sp_name_qualifier': name_id_sp_name_qualifier,
})
logger.debug('session_dump %s', session_dump)
logout = lasso.Logout(server)
if not app_settings.PRIVATE_KEY and not app_settings.PRIVATE_KEYS:
logout.setSignatureHint(lasso.PROFILE_SIGNATURE_HINT_FORBID)
logout.setSessionFromDump(session_dump)
return logout

View File

@ -15,6 +15,7 @@
from __future__ import unicode_literals
from importlib import import_module
import logging
import requests
import lasso
@ -27,6 +28,7 @@ import django.http
from django.views.generic import View
from django.http import HttpResponseRedirect, HttpResponse
from django.contrib import auth
from django.contrib.auth import get_user_model
from django.conf import settings
from django.views.decorators.csrf import csrf_exempt
from django.shortcuts import render, resolve_url
@ -38,7 +40,7 @@ from django.contrib.auth import REDIRECT_FIELD_NAME
from django.db import transaction
from django.utils.translation import ugettext as _
from . import app_settings, utils
from . import app_settings, utils, models
RETRY_LOGIN_COOKIE = 'MELLON_RETRY_LOGIN'
@ -55,6 +57,8 @@ else:
EO_NS = 'https://www.entrouvert.com/'
LOGIN_HINT = '{%s}login-hint' % EO_NS
User = get_user_model()
class HttpResponseBadRequest(django.http.HttpResponseBadRequest):
def __init__(self, *args, **kwargs):
@ -264,6 +268,12 @@ class LoginView(ProfileMixin, LogMixin, View):
if user is not None:
if user.is_active:
utils.login(request, user)
session_index = attributes['session_index']
if session_index:
models.SessionIndex.objects.get_or_create(
saml_identifier=user.saml_identifier,
session_key=request.session.session_key,
session_index=session_index)
self.log.info('user %s (NameID is %r) logged in using SAML', user,
attributes['name_id_content'])
request.session['mellon_session'] = utils.flatten_datetime(attributes)
@ -513,32 +523,103 @@ login = transaction.non_atomic_requests(csrf_exempt(LoginView.as_view()))
class LogoutView(ProfileMixin, LogMixin, View):
def get(self, request, *args, **kwargs):
if 'SAMLRequest' in request.GET:
return self.idp_logout(request)
return self.idp_logout(request, request.META['QUERY_STRING'])
elif 'SAMLResponse' in request.GET:
return self.sp_logout_response(request)
else:
return self.sp_logout_request(request)
def idp_logout(self, request):
def logout(self, request, issuer, saml_user, session_indexes, indexes):
session_keys = set(indexes.values_list('session_key', flat=True))
indexes.delete()
synchronous_logout = request.user == saml_user
asynchronous_logout = (
# the current session is not the only killed
len(session_keys) != 1
or (
# there is not current session
not request.user.is_authenticated
# or the current session is not part of the list
or request.session.session_key not in session_keys))
if asynchronous_logout:
current_session_key = request.session.session_key if request.user.is_authenticated else None
session_engine = import_module(settings.SESSION_ENGINE)
store = session_engine.SessionStore()
count = 0
for session_key in session_keys:
if session_key != current_session_key:
try:
store.delete(session_key)
count += 1
except Exception:
self.log.warning('could not delete session_key %s', session_key, exc_info=True)
if not session_indexes:
self.log.info('asynchronous logout of all sessions of user %s', saml_user)
elif count:
self.log.info('asynchronous logout of %d sessions of user %s', len(session_keys), saml_user)
if synchronous_logout:
user = request.user
auth.logout(request)
self.log.info('synchronous logout of %s', user)
def idp_logout(self, request, msg):
'''Handle logout request emitted by the IdP'''
self.profile = logout = utils.create_logout(request)
try:
logout.processRequestMsg(request.META['QUERY_STRING'])
logout.processRequestMsg(msg)
except lasso.Error as e:
return HttpResponseBadRequest('error processing logout request: %r' % e)
try:
logout.validateRequest()
except lasso.Error as e:
self.log.warning('error validating logout request: %r' % e)
issuer = request.session.get('mellon_session', {}).get('issuer')
if issuer == logout.remoteProviderId:
self.log.info('user logged out by IdP SLO request')
auth.logout(request)
issuer = force_text(logout.remoteProviderId)
session_indexes = set(force_text(sessionIndex) for sessionIndex in logout.request.sessionIndexes)
saml_identifier = models.UserSAMLIdentifier.objects.filter(
name_id=force_text(logout.nameIdentifier.content),
issuer=issuer).select_related('user').first()
if saml_identifier:
name_id_user = saml_identifier.user
indexes = models.SessionIndex.objects.select_related(
'saml_identifier').filter(
saml_identifier=saml_identifier)
if session_indexes:
indexes = indexes.filter(session_index__in=session_indexes)
# lasso has too much state :/
logout.setSessionFromDump(
utils.make_session_dump(
logout.nameIdentifier,
indexes))
try:
logout.validateRequest()
except lasso.Error as e:
self.log.warning('error validating logout request: %r' % e)
else:
if session_indexes:
self.log.info('logout requested for sessionIndexes %s', session_indexes)
else:
self.log.info('full logout requested, no sessionIndexes')
self.logout(
request,
issuer=issuer,
saml_user=name_id_user,
session_indexes=session_indexes,
indexes=indexes)
try:
logout.buildResponseMsg()
except lasso.Error as e:
return HttpResponseBadRequest('error processing logout request: %r' % e)
return HttpResponseRedirect(logout.msgUrl)
if logout.msgBody:
return HttpResponse(force_text(logout.msgBody), content_type='text/xml')
else:
return HttpResponseRedirect(logout.msgUrl)
def sp_logout_request(self, request):
'''Launch a logout request to the identity provider'''
@ -594,7 +675,7 @@ class LogoutView(ProfileMixin, LogMixin, View):
return HttpResponseRedirect(next_url)
logout = LogoutView.as_view()
logout = csrf_exempt(LogoutView.as_view())
def metadata(request, **kwargs):

View File

@ -26,6 +26,8 @@ import lasso
import pytest
from pytest import fixture
from django.contrib.sessions.models import Session
from django.contrib.auth.models import User
from django.urls import reverse
from django.utils import six
from django.utils.six.moves.urllib import parse as urlparse
@ -79,13 +81,23 @@ def sp_metadata(sp_settings, rf):
class MockIdp(object):
session_dump = None
identity_dump = None
def __init__(self, idp_metadata, private_key, sp_metadata):
self.server = server = lasso.Server.newFromBuffers(idp_metadata, private_key)
self.server.signatureMethod = lasso.SIGNATURE_METHOD_RSA_SHA256
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_SP, sp_metadata)
def reset_session_dump(self):
self.session_dump = None
def process_authn_request_redirect(self, url, auth_result=True, consent=True, msg=None):
login = lasso.Login(self.server)
if self.identity_dump:
login.setIdentityFromDump(self.identity_dump)
if self.session_dump:
login.setSessionFromDump(self.session_dump)
login.processAuthnRequestMsg(url.split('?', 1)[1])
# See
# https://docs.python.org/2/library/zlib.html#zlib.decompress
@ -154,6 +166,14 @@ class MockIdp(object):
raise NotImplementedError
if login.msgBody:
assert b'rsa-sha256' in base64.b64decode(login.msgBody)
if login.identity:
self.identity_dump = login.identity.dump()
else:
self.identity_dump = None
if login.session:
self.session_dump = login.session.dump()
else:
self.session_dump = None
return login.msgUrl, login.msgBody, login.msgRelayState
def resolve_artifact(self, soap_message):
@ -170,6 +190,27 @@ class MockIdp(object):
assert 'rsa-sha256' in lasso_decode(login.msgBody)
return '<?xml version="1.0"?>\n' + lasso_decode(login.msgBody)
def init_slo(self, full=False, method=lasso.HTTP_METHOD_REDIRECT, relay_state=None):
logout = lasso.Logout(self.server)
logout.setIdentityFromDump(self.identity_dump)
logout.setSessionFromDump(self.session_dump)
logout.initRequest(None, method)
logout.msgRelayState = relay_state
if full:
logout.request.sessionIndexes = ()
logout.request.sessionIndex = None
logout.buildRequestMsg()
return logout.msgUrl, logout.msgBody, logout.msgRelayState
def check_slo_return(self, url=None, body=None):
logout = lasso.Logout(self.server)
logout.setIdentityFromDump(self.identity_dump)
logout.setSessionFromDump(self.session_dump)
if body:
logout.processResponseMsg(force_str(body))
else:
logout.processResponseMsg(force_str(url.split('?', 1)[-1]))
def mock_artifact_resolver(self):
@all_requests
def f(url, request):
@ -198,6 +239,85 @@ def test_sso_slo(db, app, idp, caplog, sp_settings):
assert urlparse.urlparse(response['Location']).path == '/singleLogout'
def test_sso_idp_slo(db, app, idp, caplog, sp_settings):
assert Session.objects.count() == 0
assert User.objects.count() == 0
# first session
response = app.get(reverse('mellon_login') + '?next=/whatever/')
url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
assert relay_state
assert 'eo:next_url' not in str(idp.request)
assert url.endswith(reverse('mellon_login'))
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text
assert urlparse.urlparse(response['Location']).path == '/whatever/'
# start a new Lasso session
idp.reset_session_dump()
# second session
app.cookiejar.clear()
response = app.get(reverse('mellon_login') + '?next=/whatever/')
url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
assert relay_state
assert 'eo:next_url' not in str(idp.request)
assert url.endswith(reverse('mellon_login'))
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text
assert urlparse.urlparse(response['Location']).path == '/whatever/'
assert Session.objects.count() == 2
assert User.objects.count() == 1
# idp logout
url, body, relay_state = idp.init_slo()
response = app.get(url)
assert response.location.startswith('http://idp5/singleLogoutReturn?')
assert Session.objects.count() == 1
idp.check_slo_return(response.location)
def test_sso_idp_slo_full(db, app, idp, caplog, sp_settings):
assert Session.objects.count() == 0
assert User.objects.count() == 0
# first session
response = app.get(reverse('mellon_login') + '?next=/whatever/')
url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
assert relay_state
assert 'eo:next_url' not in str(idp.request)
assert url.endswith(reverse('mellon_login'))
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text
assert urlparse.urlparse(response['Location']).path == '/whatever/'
# second session
app.cookiejar.clear()
response = app.get(reverse('mellon_login') + '?next=/whatever/')
url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
assert relay_state
assert 'eo:next_url' not in str(idp.request)
assert url.endswith(reverse('mellon_login'))
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state})
assert 'created new user' in caplog.text
assert 'logged in using SAML' in caplog.text
assert urlparse.urlparse(response['Location']).path == '/whatever/'
assert Session.objects.count() == 2
assert User.objects.count() == 1
# idp logout
url, body, relay_state = idp.init_slo(full=True)
response = app.get(url)
assert response.location.startswith('http://idp5/singleLogoutReturn?')
assert Session.objects.count() == 0
idp.check_slo_return(url=response.location)
def test_sso(db, app, idp, caplog, sp_settings):
response = app.get(reverse('mellon_login'))
url, body, relay_state = idp.process_authn_request_redirect(response['Location'])
@ -336,7 +456,7 @@ def test_sso_artifact(db, app, caplog, sp_settings, idp_metadata, idp_private_ke
acs_artifact_url = url.split('testserver', 1)[1]
with HTTMock(idp.mock_artifact_resolver()):
response = app.get(acs_artifact_url, params={'RelayState': relay_state})
assert 'created new user' in caplog.text
assert 'created new user' not in caplog.text
assert 'logged in using SAML' in caplog.text
assert urlparse.urlparse(response['Location']).path == '/whatever/'