0004-misc-support-asynchronous-logout-41949.patch
mellon/templates/mellon/session_dump.xml | ||
---|---|---|
1 | 1 |
<ns0:Session xmlns:ns0="http://www.entrouvert.org/namespaces/lasso/0.0" xmlns:ns1="urn:oasis:names:tc:SAML:2.0:assertion" Version="2"> |
2 |
{% for session_info in session_infos %} |
|
2 | 3 |
<ns0:NidAndSessionIndex AssertionID="" |
3 |
ProviderID="{{ entity_id }}" |
|
4 |
SessionIndex="{{ session_index }}"> |
|
5 |
<ns1:NameID Format="{{ name_id_format }}" |
|
6 |
{% if name_id_name_qualifier %}NameQualifier="{{ name_id_name_qualifier }}"{% endif %}
|
|
7 |
{% if name_id_sp_name_qualifier %}SPNameQualifier="{{ name_id_sp_name_qualifier }}"{% endif %}
|
|
8 |
>{{ name_id_content }}</ns1:NameID> |
|
4 |
ProviderID="{{ session_info.entity_id }}"
|
|
5 |
SessionIndex="{{ session_info.session_index }}">
|
|
6 |
<ns1:NameID Format="{{ session_info.name_id_format }}"
|
|
7 |
{% if session_info.name_id_name_qualifier %}NameQualifier="{{ session_info.name_id_name_qualifier }}"{% endif %}
|
|
8 |
{% if session_info.name_id_sp_name_qualifier %}SPNameQualifier="{{ session_info.name_id_sp_name_qualifier }}"{% endif %}
|
|
9 |
>{{ session_info.name_id_content }}</ns1:NameID>
|
|
9 | 10 |
</ns0:NidAndSessionIndex> |
11 |
{% endfor %} |
|
10 | 12 |
</ns0:Session> |
mellon/utils.py | ||
---|---|---|
22 | 22 |
import isodate |
23 | 23 |
from xml.parsers import expat |
24 | 24 | |
25 |
import django |
|
26 | 25 |
from django.contrib import auth |
27 | 26 |
from django.template.loader import render_to_string |
28 | 27 |
from django.urls import reverse |
28 |
from django.utils.encoding import force_text |
|
29 | 29 |
from django.utils.timezone import make_aware, now, make_naive, is_aware, get_default_timezone |
30 | 30 |
from django.conf import settings |
31 | 31 |
from django.utils.six.moves.urllib.parse import urlparse |
... | ... | |
33 | 33 | |
34 | 34 |
from . import app_settings |
35 | 35 | |
36 |
logger = logging.getLogger(__name__) |
|
36 | 37 | |
37 | 38 |
def create_metadata(request): |
38 | 39 |
entity_id = reverse('mellon_metadata') |
... | ... | |
64 | 65 | |
65 | 66 | |
66 | 67 |
def create_server(request): |
67 |
logger = logging.getLogger(__name__) |
|
68 | 68 |
root = request.build_absolute_uri('/') |
69 | 69 |
cache = getattr(settings, '_MELLON_SERVER_CACHE', {}) |
70 | 70 |
if root not in cache: |
... | ... | |
203 | 203 |
return idp.get(name) or getattr(app_settings, name, default) |
204 | 204 | |
205 | 205 | |
206 |
def make_session_dump(lasso_name_id, indexes): |
|
207 |
session_infos = [] |
|
208 |
name_id = force_text(lasso_name_id.content) |
|
209 |
name_id_format = force_text(lasso_name_id.format) |
|
210 |
name_qualifier = lasso_name_id.nameQualifier and force_text(lasso_name_id.nameQualifier) |
|
211 |
sp_name_qualifier = lasso_name_id.spNameQualifier and force_text(lasso_name_id.spNameQualifier) |
|
212 |
for index in indexes: |
|
213 |
issuer = index.saml_identifier.issuer |
|
214 |
session_infos.append({ |
|
215 |
'entity_id': issuer, |
|
216 |
'session_index': index.session_index, |
|
217 |
'name_id_content': name_id, |
|
218 |
'name_id_format': name_id_format, |
|
219 |
'name_id_name_qualifier': name_qualifier, |
|
220 |
'name_id_sp_name_qualifier': sp_name_qualifier, |
|
221 |
}) |
|
222 |
session_dump = render_to_string('mellon/session_dump.xml', {'session_infos': session_infos}) |
|
223 |
return session_dump |
|
224 | ||
225 | ||
206 | 226 |
def create_logout(request): |
207 |
logger = logging.getLogger(__name__) |
|
208 | 227 |
server = create_server(request) |
209 |
mellon_session = request.session.get('mellon_session', {}) |
|
210 |
entity_id = mellon_session.get('issuer') |
|
211 |
session_index = mellon_session.get('session_index') |
|
212 |
name_id_format = mellon_session.get('name_id_format') |
|
213 |
name_id_content = mellon_session.get('name_id_content') |
|
214 |
name_id_name_qualifier = mellon_session.get('name_id_name_qualifier') |
|
215 |
name_id_sp_name_qualifier = mellon_session.get('name_id_sp_name_qualifier') |
|
216 |
session_dump = render_to_string('mellon/session_dump.xml', { |
|
217 |
'entity_id': entity_id, |
|
218 |
'session_index': session_index, |
|
219 |
'name_id_format': name_id_format, |
|
220 |
'name_id_content': name_id_content, |
|
221 |
'name_id_name_qualifier': name_id_name_qualifier, |
|
222 |
'name_id_sp_name_qualifier': name_id_sp_name_qualifier, |
|
223 |
}) |
|
224 |
logger.debug('session_dump %s', session_dump) |
|
225 | 228 |
logout = lasso.Logout(server) |
226 | 229 |
if not app_settings.PRIVATE_KEY and not app_settings.PRIVATE_KEYS: |
227 | 230 |
logout.setSignatureHint(lasso.PROFILE_SIGNATURE_HINT_FORBID) |
228 |
logout.setSessionFromDump(session_dump) |
|
229 | 231 |
return logout |
230 | 232 | |
231 | 233 |
mellon/views.py | ||
---|---|---|
15 | 15 | |
16 | 16 |
from __future__ import unicode_literals |
17 | 17 | |
18 |
from importlib import import_module |
|
18 | 19 |
import logging |
19 | 20 |
import requests |
20 | 21 |
import lasso |
... | ... | |
27 | 28 |
from django.views.generic import View |
28 | 29 |
from django.http import HttpResponseRedirect, HttpResponse |
29 | 30 |
from django.contrib import auth |
31 |
from django.contrib.auth import get_user_model |
|
30 | 32 |
from django.conf import settings |
31 | 33 |
from django.views.decorators.csrf import csrf_exempt |
32 | 34 |
from django.shortcuts import render, resolve_url |
... | ... | |
38 | 40 |
from django.db import transaction |
39 | 41 |
from django.utils.translation import ugettext as _ |
40 | 42 | |
41 |
from . import app_settings, utils |
|
43 |
from . import app_settings, utils, models
|
|
42 | 44 | |
43 | 45 | |
44 | 46 |
RETRY_LOGIN_COOKIE = 'MELLON_RETRY_LOGIN' |
... | ... | |
55 | 57 |
EO_NS = 'https://www.entrouvert.com/' |
56 | 58 |
LOGIN_HINT = '{%s}login-hint' % EO_NS |
57 | 59 | |
60 |
User = get_user_model() |
|
61 | ||
58 | 62 | |
59 | 63 |
class HttpResponseBadRequest(django.http.HttpResponseBadRequest): |
60 | 64 |
def __init__(self, *args, **kwargs): |
... | ... | |
256 | 260 |
if user is not None: |
257 | 261 |
if user.is_active: |
258 | 262 |
utils.login(request, user) |
263 |
session_index = attributes['session_index'] |
|
264 |
if session_index: |
|
265 |
models.SessionIndex.objects.get_or_create( |
|
266 |
saml_identifier=user.saml_identifier, |
|
267 |
session_key=request.session.session_key, |
|
268 |
session_index=session_index) |
|
259 | 269 |
self.log.info('user %s (NameID is %r) logged in using SAML', user, |
260 | 270 |
attributes['name_id_content']) |
261 | 271 |
request.session['mellon_session'] = utils.flatten_datetime(attributes) |
... | ... | |
505 | 515 |
class LogoutView(ProfileMixin, LogMixin, View): |
506 | 516 |
def get(self, request, *args, **kwargs): |
507 | 517 |
if 'SAMLRequest' in request.GET: |
508 |
return self.idp_logout(request) |
|
518 |
return self.idp_logout(request, request.META['QUERY_STRING'])
|
|
509 | 519 |
elif 'SAMLResponse' in request.GET: |
510 | 520 |
return self.sp_logout_response(request) |
511 | 521 |
else: |
512 | 522 |
return self.sp_logout_request(request) |
513 | 523 | |
514 |
def idp_logout(self, request): |
|
524 |
def logout(self, request, issuer, saml_user, session_indexes, indexes): |
|
525 |
session_keys = set(indexes.values_list('session_key', flat=True)) |
|
526 |
indexes.delete() |
|
527 | ||
528 |
synchronous_logout = request.user == saml_user |
|
529 |
asynchronous_logout = ( |
|
530 |
# the current session is not the only killed |
|
531 |
len(session_keys) != 1 |
|
532 |
or ( |
|
533 |
# there is not current session |
|
534 |
not request.user.is_authenticated() |
|
535 |
# or the current session is not part of the list |
|
536 |
or request.session.session_key not in session_keys)) |
|
537 | ||
538 |
if asynchronous_logout: |
|
539 |
current_session_key = request.session.session_key if request.user.is_authenticated() else None |
|
540 | ||
541 |
session_engine = import_module(settings.SESSION_ENGINE) |
|
542 |
store = session_engine.SessionStore() |
|
543 | ||
544 |
count = 0 |
|
545 |
for session_key in session_keys: |
|
546 |
if session_key != current_session_key: |
|
547 |
try: |
|
548 |
store.delete(session_key) |
|
549 |
count += 1 |
|
550 |
except Exception: |
|
551 |
self.log.warning('could not delete session_key %s', session_key, exc_info=True) |
|
552 |
if not session_indexes: |
|
553 |
self.log.info('asynchronous logout of all sessions of user %s', saml_user) |
|
554 |
elif count: |
|
555 |
self.log.info('asynchronous logout of %d sessions of user %s', len(session_keys), saml_user) |
|
556 | ||
557 |
if synchronous_logout: |
|
558 |
user = request.user |
|
559 |
auth.logout(request) |
|
560 |
self.log.info('synchronous logout of %s', user) |
|
561 | ||
562 |
def idp_logout(self, request, msg): |
|
515 | 563 |
'''Handle logout request emitted by the IdP''' |
516 | 564 |
self.profile = logout = utils.create_logout(request) |
517 | 565 |
try: |
518 |
logout.processRequestMsg(request.META['QUERY_STRING'])
|
|
566 |
logout.processRequestMsg(msg)
|
|
519 | 567 |
except lasso.Error as e: |
520 | 568 |
return HttpResponseBadRequest('error processing logout request: %r' % e) |
521 |
try: |
|
522 |
logout.validateRequest() |
|
523 |
except lasso.Error as e: |
|
524 |
self.log.warning('error validating logout request: %r' % e) |
|
525 |
issuer = request.session.get('mellon_session', {}).get('issuer') |
|
526 |
if issuer == logout.remoteProviderId: |
|
527 |
self.log.info('user logged out by IdP SLO request') |
|
528 |
auth.logout(request) |
|
569 | ||
570 |
issuer = force_text(logout.remoteProviderId) |
|
571 |
session_indexes = set(force_text(sessionIndex) for sessionIndex in logout.request.sessionIndexes) |
|
572 | ||
573 |
saml_identifier = models.UserSAMLIdentifier.objects.filter( |
|
574 |
name_id=force_text(logout.nameIdentifier.content), |
|
575 |
issuer=issuer).select_related('user').first() |
|
576 | ||
577 |
if saml_identifier: |
|
578 |
name_id_user = saml_identifier.user |
|
579 |
indexes = models.SessionIndex.objects.select_related( |
|
580 |
'saml_identifier').filter( |
|
581 |
saml_identifier=saml_identifier) |
|
582 |
if session_indexes: |
|
583 |
indexes = indexes.filter(session_index__in=session_indexes) |
|
584 | ||
585 |
# lasso has too much state :/ |
|
586 |
logout.setSessionFromDump( |
|
587 |
utils.make_session_dump( |
|
588 |
logout.nameIdentifier, |
|
589 |
indexes)) |
|
590 | ||
591 |
try: |
|
592 |
logout.validateRequest() |
|
593 |
except lasso.Error as e: |
|
594 |
self.log.warning('error validating logout request: %r' % e) |
|
595 |
else: |
|
596 |
if session_indexes: |
|
597 |
self.log.info('logout requested for sessionIndexes %s', session_indexes) |
|
598 |
else: |
|
599 |
self.log.info('full logout requested, no sessionIndexes') |
|
600 |
self.logout( |
|
601 |
request, |
|
602 |
issuer=issuer, |
|
603 |
saml_user=name_id_user, |
|
604 |
session_indexes=session_indexes, |
|
605 |
indexes=indexes) |
|
606 | ||
529 | 607 |
try: |
530 | 608 |
logout.buildResponseMsg() |
531 | 609 |
except lasso.Error as e: |
532 | 610 |
return HttpResponseBadRequest('error processing logout request: %r' % e) |
533 |
return HttpResponseRedirect(logout.msgUrl) |
|
611 |
if logout.msgBody: |
|
612 |
return HttpResponse(force_text(logout.msgBody), content_type='text/xml') |
|
613 |
else: |
|
614 |
return HttpResponseRedirect(logout.msgUrl) |
|
534 | 615 | |
535 | 616 |
def sp_logout_request(self, request): |
536 | 617 |
'''Launch a logout request to the identity provider''' |
... | ... | |
586 | 667 |
return HttpResponseRedirect(next_url) |
587 | 668 | |
588 | 669 | |
589 |
logout = LogoutView.as_view()
|
|
670 |
logout = csrf_exempt(LogoutView.as_view())
|
|
590 | 671 | |
591 | 672 | |
592 | 673 |
def metadata(request, **kwargs): |
tests/test_sso_slo.py | ||
---|---|---|
26 | 26 |
import pytest |
27 | 27 |
from pytest import fixture |
28 | 28 | |
29 |
from django.contrib.sessions.models import Session |
|
30 |
from django.contrib.auth.models import User |
|
29 | 31 |
from django.urls import reverse |
30 | 32 |
from django.utils import six |
31 | 33 |
from django.utils.six.moves.urllib import parse as urlparse |
... | ... | |
78 | 80 | |
79 | 81 | |
80 | 82 |
class MockIdp(object): |
83 |
session_dump = None |
|
84 |
identity_dump = None |
|
85 | ||
81 | 86 |
def __init__(self, idp_metadata, private_key, sp_metadata): |
82 | 87 |
self.server = server = lasso.Server.newFromBuffers(idp_metadata, private_key) |
83 | 88 |
self.server.signatureMethod = lasso.SIGNATURE_METHOD_RSA_SHA256 |
84 | 89 |
server.addProviderFromBuffer(lasso.PROVIDER_ROLE_SP, sp_metadata) |
85 | 90 | |
91 |
def reset_session_dump(self): |
|
92 |
self.session_dump = None |
|
93 | ||
86 | 94 |
def process_authn_request_redirect(self, url, auth_result=True, consent=True, msg=None): |
87 | 95 |
login = lasso.Login(self.server) |
96 |
if self.identity_dump: |
|
97 |
login.setIdentityFromDump(self.identity_dump) |
|
98 |
if self.session_dump: |
|
99 |
login.setSessionFromDump(self.session_dump) |
|
88 | 100 |
login.processAuthnRequestMsg(url.split('?', 1)[1]) |
89 | 101 |
# See |
90 | 102 |
# https://docs.python.org/2/library/zlib.html#zlib.decompress |
... | ... | |
151 | 163 |
raise NotImplementedError |
152 | 164 |
if login.msgBody: |
153 | 165 |
assert b'rsa-sha256' in base64.b64decode(login.msgBody) |
166 |
if login.identity: |
|
167 |
self.identity_dump = login.identity.dump() |
|
168 |
else: |
|
169 |
self.identity_dump = None |
|
170 |
if login.session: |
|
171 |
self.session_dump = login.session.dump() |
|
172 |
else: |
|
173 |
self.session_dump = None |
|
154 | 174 |
return login.msgUrl, login.msgBody, login.msgRelayState |
155 | 175 | |
156 | 176 |
def resolve_artifact(self, soap_message): |
... | ... | |
167 | 187 |
assert 'rsa-sha256' in login.msgBody |
168 | 188 |
return '<?xml version="1.0"?>\n' + login.msgBody |
169 | 189 | |
190 |
def init_slo(self, full=False, method=lasso.HTTP_METHOD_REDIRECT, relay_state=None): |
|
191 |
logout = lasso.Logout(self.server) |
|
192 |
logout.setIdentityFromDump(self.identity_dump) |
|
193 |
logout.setSessionFromDump(self.session_dump) |
|
194 |
logout.initRequest(None, method) |
|
195 |
logout.msgRelayState = relay_state |
|
196 |
if full: |
|
197 |
logout.request.sessionIndexes = () |
|
198 |
logout.request.sessionIndex = None |
|
199 |
logout.buildRequestMsg() |
|
200 |
return logout.msgUrl, logout.msgBody, logout.msgRelayState |
|
201 | ||
202 |
def check_slo_return(self, url=None, body=None): |
|
203 |
logout = lasso.Logout(self.server) |
|
204 |
logout.setIdentityFromDump(self.identity_dump) |
|
205 |
logout.setSessionFromDump(self.session_dump) |
|
206 |
if body: |
|
207 |
logout.processResponseMsg(force_str(body)) |
|
208 |
else: |
|
209 |
logout.processResponseMsg(force_str(url.split('?', 1)[-1])) |
|
210 | ||
170 | 211 |
def mock_artifact_resolver(self): |
171 | 212 |
@all_requests |
172 | 213 |
def f(url, request): |
... | ... | |
195 | 236 |
assert urlparse.urlparse(response['Location']).path == '/singleLogout' |
196 | 237 | |
197 | 238 | |
239 |
def test_sso_idp_slo(db, app, idp, caplog, sp_settings): |
|
240 |
assert Session.objects.count() == 0 |
|
241 |
assert User.objects.count() == 0 |
|
242 | ||
243 |
# first session |
|
244 |
response = app.get(reverse('mellon_login') + '?next=/whatever/') |
|
245 |
url, body, relay_state = idp.process_authn_request_redirect(response['Location']) |
|
246 |
assert relay_state |
|
247 |
assert 'eo:next_url' not in str(idp.request) |
|
248 |
assert url.endswith(reverse('mellon_login')) |
|
249 |
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) |
|
250 |
assert 'created new user' in caplog.text |
|
251 |
assert 'logged in using SAML' in caplog.text |
|
252 |
assert urlparse.urlparse(response['Location']).path == '/whatever/' |
|
253 | ||
254 |
# start a new Lasso session |
|
255 |
idp.reset_session_dump() |
|
256 | ||
257 |
# second session |
|
258 |
app.cookiejar.clear() |
|
259 |
response = app.get(reverse('mellon_login') + '?next=/whatever/') |
|
260 |
url, body, relay_state = idp.process_authn_request_redirect(response['Location']) |
|
261 |
assert relay_state |
|
262 |
assert 'eo:next_url' not in str(idp.request) |
|
263 |
assert url.endswith(reverse('mellon_login')) |
|
264 |
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) |
|
265 |
assert 'created new user' in caplog.text |
|
266 |
assert 'logged in using SAML' in caplog.text |
|
267 |
assert urlparse.urlparse(response['Location']).path == '/whatever/' |
|
268 | ||
269 |
assert Session.objects.count() == 2 |
|
270 |
assert User.objects.count() == 1 |
|
271 | ||
272 |
# idp logout |
|
273 |
url, body, relay_state = idp.init_slo() |
|
274 |
response = app.get(url) |
|
275 |
assert response.location.startswith('http://idp5/singleLogoutReturn?') |
|
276 |
assert Session.objects.count() == 1 |
|
277 |
idp.check_slo_return(response.location) |
|
278 | ||
279 | ||
280 |
def test_sso_idp_slo_full(db, app, idp, caplog, sp_settings): |
|
281 |
assert Session.objects.count() == 0 |
|
282 |
assert User.objects.count() == 0 |
|
283 | ||
284 |
# first session |
|
285 |
response = app.get(reverse('mellon_login') + '?next=/whatever/') |
|
286 |
url, body, relay_state = idp.process_authn_request_redirect(response['Location']) |
|
287 |
assert relay_state |
|
288 |
assert 'eo:next_url' not in str(idp.request) |
|
289 |
assert url.endswith(reverse('mellon_login')) |
|
290 |
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) |
|
291 |
assert 'created new user' in caplog.text |
|
292 |
assert 'logged in using SAML' in caplog.text |
|
293 |
assert urlparse.urlparse(response['Location']).path == '/whatever/' |
|
294 | ||
295 |
# second session |
|
296 |
app.cookiejar.clear() |
|
297 |
response = app.get(reverse('mellon_login') + '?next=/whatever/') |
|
298 |
url, body, relay_state = idp.process_authn_request_redirect(response['Location']) |
|
299 |
assert relay_state |
|
300 |
assert 'eo:next_url' not in str(idp.request) |
|
301 |
assert url.endswith(reverse('mellon_login')) |
|
302 |
response = app.post(reverse('mellon_login'), params={'SAMLResponse': body, 'RelayState': relay_state}) |
|
303 |
assert 'created new user' in caplog.text |
|
304 |
assert 'logged in using SAML' in caplog.text |
|
305 |
assert urlparse.urlparse(response['Location']).path == '/whatever/' |
|
306 | ||
307 |
assert Session.objects.count() == 2 |
|
308 |
assert User.objects.count() == 1 |
|
309 | ||
310 |
# idp logout |
|
311 |
url, body, relay_state = idp.init_slo(full=True) |
|
312 |
response = app.get(url) |
|
313 |
assert response.location.startswith('http://idp5/singleLogoutReturn?') |
|
314 |
assert Session.objects.count() == 0 |
|
315 |
idp.check_slo_return(url=response.location) |
|
316 | ||
317 | ||
198 | 318 |
def test_sso(db, app, idp, caplog, sp_settings): |
199 | 319 |
response = app.get(reverse('mellon_login')) |
200 | 320 |
url, body, relay_state = idp.process_authn_request_redirect(response['Location']) |
... | ... | |
330 | 450 |
acs_artifact_url = url.split('testserver', 1)[1] |
331 | 451 |
with HTTMock(idp.mock_artifact_resolver()): |
332 | 452 |
response = app.get(acs_artifact_url, params={'RelayState': relay_state}) |
333 |
assert 'created new user' in caplog.text |
|
453 |
assert 'created new user' not in caplog.text
|
|
334 | 454 |
assert 'logged in using SAML' in caplog.text |
335 | 455 |
assert urlparse.urlparse(response['Location']).path == '/whatever/' |
336 | 456 | |
337 |
- |