diff --git a/mellon/templates/mellon/session_dump.xml b/mellon/templates/mellon/session_dump.xml index 5fc9635..cbb83b4 100644 --- a/mellon/templates/mellon/session_dump.xml +++ b/mellon/templates/mellon/session_dump.xml @@ -1,10 +1,12 @@ + {% for session_info in session_infos %} - {{ name_id_content }} + ProviderID="{{ session_info.entity_id }}" + SessionIndex="{{ session_info.session_index }}"> + {{ session_info.name_id_content }} + {% endfor %} diff --git a/mellon/utils.py b/mellon/utils.py index 7fe205e..9b9dcc0 100644 --- a/mellon/utils.py +++ b/mellon/utils.py @@ -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 diff --git a/mellon/views.py b/mellon/views.py index 899989f..41018f4 100644 --- a/mellon/views.py +++ b/mellon/views.py @@ -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): diff --git a/tests/test_sso_slo.py b/tests/test_sso_slo.py index 97285d3..8283ca4 100644 --- a/tests/test_sso_slo.py +++ b/tests/test_sso_slo.py @@ -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 '\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/'