Skip to content

Commit fa2003a

Browse files
committed
SameSite cookie hanlders in SamlSessionMiddleware - thanks to Andre Borie for guidelines
1 parent 5903075 commit fa2003a

File tree

7 files changed

+140
-35
lines changed

7 files changed

+140
-35
lines changed

.coverage

52 KB
Binary file not shown.

README.rst

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ do to make sure it is compatible with your Django version and environment.
6868
you run any other Django application test suite. Just type ``python manage.py
6969
test djangosaml2``.
7070

71-
Python 2 users need to ``pip install djangosaml2[test]`` in order to run the
71+
Python users need to ``pip install djangosaml2[test]`` in order to run the
7272
tests.
7373

7474
Then you have to add the ``djangosaml2.backends.Saml2Backend``
@@ -106,6 +106,10 @@ If you want to allow several authentication mechanisms in your project
106106
you should set the LOGIN_URL option to another view and put a link in such
107107
view to the ``/saml2/login/`` view.
108108

109+
Add the SAML Session Middleware as follow::
110+
111+
MIDDLEWARE.append('djangosaml2.middleware.SamlSessionMiddleware')
112+
109113
Handling Post-Login Redirects
110114
-----------------------------
111115
It is often desireable for the client to maintain the URL state (or at least manage it) so that
@@ -116,11 +120,11 @@ host matches the output of get_host(). However, in some cases it becomes desire
116120
hostnames to be used for the post-login redirect. In such cases, the setting::
117121

118122
SAML_ALLOWED_HOSTS = []
119-
123+
120124
May be set to a list of allowed post-login redirect hostnames (note, the URL components beyond the hostname
121-
may be specified by the client - typically with the ?next= parameter.)
125+
may be specified by the client - typically with the ?next= parameter.)
122126

123-
In the absence of a ?next= parameter, the LOGIN_REDIRECT_URL setting will be used (assuming the destination hostname
127+
In the absence of a ?next= parameter, the LOGIN_REDIRECT_URL setting will be used (assuming the destination hostname
124128
either matches the output of get_host() or is included in the SAML_ALLOWED_HOSTS setting)
125129

126130

@@ -205,11 +209,11 @@ We will see a typical configuration for protecting a Django project::
205209
},
206210
# Mandates that the identity provider MUST authenticate the
207211
# presenter directly rather than rely on a previous security context.
208-
'force_authn': False,
209-
212+
'force_authn': False,
213+
210214
# Enable AllowCreate in NameIDPolicy.
211215
'name_id_format_allow_create': False,
212-
216+
213217
# attributes that this project need to identify a user
214218
'required_attributes': ['uid'],
215219

@@ -327,6 +331,15 @@ setting::
327331
SAML_CONFIG_LOADER = 'python.path.to.your.callable'
328332

329333

334+
SameSite cookie
335+
...............
336+
337+
By default, djangosaml2 handle the saml2 session in a separate cookie.
338+
The storage linked to it is accessible by default at `request.saml_session`.
339+
You can even configure this using::
340+
341+
SAML_SESSION_COOKIE_NAME = 'saml_session'
342+
330343
Custom error handler
331344
....................
332345

@@ -544,12 +557,16 @@ Unit tests
544557
You can also run the unit tests as follows::
545558

546559
pip install -r requirements-dev.txt
560+
# or
561+
pip install djangosaml2[test]
547562
python3 tests/manage.py migrate
548-
563+
564+
then::
565+
549566
python tests/run_tests.py
550567

551568
or::
552-
569+
553570
cd tests/
554571
./manage.py test djangosaml2
555572

djangosaml2/cache.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def delete(self, saml2_session_id):
6363
del self._db[saml2_session_id]
6464
self._db.sync()
6565

66+
def sync(self):
67+
self._db.sync()
68+
6669

6770
class IdentityCache(Cache):
6871
"""Handles information about the users that have been succesfully

djangosaml2/middleware.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import time
2+
from importlib import import_module
3+
4+
from django.conf import settings
5+
from django.contrib.sessions.backends.base import UpdateError
6+
from django.contrib.sessions.middleware import SessionMiddleware
7+
from django.core.exceptions import SuspiciousOperation
8+
from django.utils.cache import patch_vary_headers
9+
from django.utils.http import http_date
10+
11+
12+
class SamlSessionMiddleware(SessionMiddleware):
13+
session_name = getattr(settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session')
14+
15+
def process_request(self, request):
16+
session_key = request.COOKIES.get(self.session_name, None)
17+
setattr(request, self.session_name, self.SessionStore(session_key))
18+
19+
def process_response(self, request, response):
20+
"""
21+
If request.saml_session was modified, or if the configuration is to save the
22+
session every time, save the changes and set a session cookie or delete
23+
the session cookie if the session has been emptied.
24+
"""
25+
try:
26+
accessed = getattr(request, self.session_name).accessed
27+
modified = getattr(request, self.session_name).modified
28+
empty = getattr(request, self.session_name).is_empty()
29+
except AttributeError:
30+
return response
31+
# First check if we need to delete this cookie.
32+
# The session should be deleted only if the session is entirely empty.
33+
if self.session_name in request.COOKIES and empty:
34+
response.delete_cookie(
35+
self.session_name,
36+
path=settings.SESSION_COOKIE_PATH,
37+
domain=settings.SESSION_COOKIE_DOMAIN,
38+
samesite=None,
39+
)
40+
patch_vary_headers(response, ('Cookie',))
41+
else:
42+
if accessed:
43+
patch_vary_headers(response, ('Cookie',))
44+
if (modified or settings.SESSION_SAVE_EVERY_REQUEST) and not empty:
45+
if request.session.get_expire_at_browser_close():
46+
max_age = None
47+
expires = None
48+
else:
49+
max_age = getattr(request, self.session_name).get_expiry_age()
50+
expires_time = time.time() + max_age
51+
expires = http_date(expires_time)
52+
# Save the session data and refresh the client cookie.
53+
# Skip session save for 500 responses, refs #3881.
54+
if response.status_code != 500:
55+
try:
56+
getattr(request, self.session_name).save()
57+
except UpdateError:
58+
raise SuspiciousOperation(
59+
"The request's session was deleted before the "
60+
"request completed. The user may have logged "
61+
"out in a concurrent request, for example."
62+
)
63+
response.set_cookie(
64+
self.session_name,
65+
getattr(request, self.session_name).session_key,
66+
max_age=max_age,
67+
expires=expires, domain=settings.SESSION_COOKIE_DOMAIN,
68+
path=settings.SESSION_COOKIE_PATH,
69+
secure=settings.SESSION_COOKIE_SECURE or None,
70+
httponly=settings.SESSION_COOKIE_HTTPONLY or None,
71+
samesite=None
72+
)
73+
return response

djangosaml2/tests/__init__.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ def remove_variable_attributes(xml_string):
8888
xml_string)
8989

9090
return xml_string
91-
91+
9292
self.assertEqual(remove_variable_attributes(real_xml),
9393
remove_variable_attributes(expected_xmls))
9494

@@ -129,7 +129,7 @@ def test_unsigned_post_authn_request(self):
129129
response_parser = SAMLPostFormParser()
130130
response_parser.feed(response.content.decode('utf-8'))
131131
saml_request = response_parser.saml_request_value
132-
132+
133133
self.assertIsNotNone(saml_request)
134134
if 'AuthnRequest xmlns' not in base64.b64decode(saml_request).decode('utf-8'):
135135
raise Exception('test_unsigned_post_authn_request: Not a valid AuthnRequest')
@@ -149,7 +149,7 @@ def test_login_evil_redirect(self):
149149
response = self.client.get(reverse('saml2_login') + '?next=http://evil.com')
150150
url = urlparse(response['Location'])
151151
params = parse_qs(url.query)
152-
152+
153153
self.assertEqual(params['RelayState'], [settings.LOGIN_REDIRECT_URL, ])
154154

155155
def test_login_one_idp(self):
@@ -171,7 +171,7 @@ def test_login_one_idp(self):
171171
params = parse_qs(url.query)
172172
self.assertIn('SAMLRequest', params)
173173
self.assertIn('RelayState', params)
174-
174+
175175
saml_request = params['SAMLRequest'][0]
176176
if 'AuthnRequest xmlns' not in decode_base64_and_inflate(saml_request).decode('utf-8'):
177177
raise Exception('Not a valid AuthnRequest')
@@ -182,7 +182,7 @@ def test_login_one_idp(self):
182182
response = self.client.get(reverse('saml2_login'), {'next': next})
183183
self.assertEqual(response.status_code, 302)
184184
location = response['Location']
185-
185+
186186
url = urlparse(location)
187187
self.assertEqual(url.hostname, 'idp.example.com')
188188
self.assertEqual(url.path, '/simplesaml/saml2/idp/SSOService.php')
@@ -359,12 +359,12 @@ def test_logout(self):
359359
self.assertIn('SAMLRequest', params)
360360

361361
saml_request = params['SAMLRequest'][0]
362-
362+
363363
if 'LogoutRequest xmlns' not in decode_base64_and_inflate(saml_request).decode('utf-8'):
364364
raise Exception('Not a valid LogoutRequest')
365365

366-
367-
366+
367+
368368
def test_logout_service_local(self):
369369
settings.SAML_CONFIG = conf.create_conf(
370370
sp_host='sp.example.com',
@@ -435,7 +435,7 @@ def test_logout_service_global(self):
435435
params = parse_qs(url.query)
436436
self.assertIn('SAMLResponse', params)
437437
saml_response = params['SAMLResponse'][0]
438-
438+
439439
if 'Response xmlns' not in decode_base64_and_inflate(saml_response).decode('utf-8'):
440440
raise Exception('Not a valid Response')
441441

@@ -636,7 +636,10 @@ def test_custom_conf_loader_from_view(self):
636636
request.user = AnonymousUser()
637637
middleware = SessionMiddleware()
638638
middleware.process_request(request)
639-
request.session.save()
639+
640+
saml_session_name = getattr(settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session')
641+
getattr(request, saml_session_name).save()
642+
640643
response = views.login(request, config_loader_path)
641644
self.assertEqual(response.status_code, 302)
642645
location = response['Location']

djangosaml2/utils.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,7 @@ def validate_referral_url(request, url):
9595
return settings.LOGIN_REDIRECT_URL
9696
else:
9797
return url
98+
99+
def get_saml_request_session(request):
100+
session_name = getattr(settings, 'SAML_SESSION_COOKIE_NAME', 'saml_session')
101+
return getattr(request, session_name)

djangosaml2/views.py

Lines changed: 21 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@
5353
from .signals import post_authenticated
5454
from .utils import (available_idps, fail_acs_response, get_custom_setting,
5555
get_idp_sso_supported_bindings, get_location,
56-
validate_referral_url)
56+
validate_referral_url, get_saml_request_session)
5757

5858
try:
5959
from django.contrib.auth.views import LogoutView
@@ -246,10 +246,11 @@ def login(request,
246246
else:
247247
raise UnsupportedBinding('Unsupported binding: %s', binding)
248248

249+
saml_session = get_saml_request_session(request)
249250
# success, so save the session ID and return our response
250-
logger.debug('Saving the session_id in the OutstandingQueries cache')
251-
oq_cache = OutstandingQueriesCache(request.session)
251+
oq_cache = OutstandingQueriesCache(saml_session)
252252
oq_cache.set(session_id, came_from)
253+
logger.debug('Saving the session_id "{}" in the OutstandingQueries cache'.format(oq_cache.__dict__))
253254
return http_response
254255

255256

@@ -286,9 +287,10 @@ def post(self,
286287
logger.warning('Missing "SAMLResponse" parameter in POST data.')
287288
raise SuspiciousOperation
288289

289-
client = Saml2Client(conf, identity_cache=IdentityCache(self.request.session))
290-
291-
oq_cache = OutstandingQueriesCache(self.request.session)
290+
saml_session = get_saml_request_session(request)
291+
client = Saml2Client(conf, identity_cache=IdentityCache(saml_session))
292+
oq_cache = OutstandingQueriesCache(saml_session)
293+
oq_cache.sync()
292294
outstanding_queries = oq_cache.outstanding_queries()
293295

294296
try:
@@ -343,7 +345,7 @@ def post(self,
343345
return fail_acs_response(request, exception=PermissionDenied('No user could be authenticated.'))
344346

345347
auth.login(self.request, user)
346-
_set_subject_id(self.request.session, session_info['name_id'])
348+
_set_subject_id(saml_session, session_info['name_id'])
347349
logger.debug("User %s authenticated via SSO.", user)
348350
logger.debug('Sending the post_authenticated signal')
349351

@@ -403,12 +405,13 @@ def echo_attributes(request,
403405
config_loader_path=None,
404406
template='djangosaml2/echo_attributes.html'):
405407
"""Example view that echo the SAML attributes of an user"""
406-
state = StateCache(request.session)
408+
saml_session = get_saml_request_session(request)
409+
state = StateCache(saml_session)
407410
conf = get_config(config_loader_path, request)
408411

409412
client = Saml2Client(conf, state_cache=state,
410-
identity_cache=IdentityCache(request.session))
411-
subject_id = _get_subject_id(request.session)
413+
identity_cache=IdentityCache(saml_session))
414+
subject_id = _get_subject_id(saml_session)
412415
try:
413416
identity = client.users.get_identity(subject_id,
414417
check_not_on_or_after=False)
@@ -425,12 +428,13 @@ def logout(request, config_loader_path=None):
425428
This view initiates the SAML2 Logout request
426429
using the pysaml2 library to create the LogoutRequest.
427430
"""
428-
state = StateCache(request.session)
431+
saml_session = get_saml_request_session(request)
432+
state = StateCache(saml_session)
429433
conf = get_config(config_loader_path, request)
430434

431435
client = Saml2Client(conf, state_cache=state,
432-
identity_cache=IdentityCache(request.session))
433-
subject_id = _get_subject_id(request.session)
436+
identity_cache=IdentityCache(saml_session))
437+
subject_id = _get_subject_id(saml_session)
434438
if subject_id is None:
435439
logger.warning(
436440
'The session does not contain the subject id for user %s',
@@ -508,9 +512,10 @@ def do_logout_service(request, data, binding, config_loader_path=None, next_page
508512
logger.debug('Logout service started')
509513
conf = get_config(config_loader_path, request)
510514

511-
state = StateCache(request.session)
515+
saml_session = get_saml_request_session(request)
516+
state = StateCache(saml_session)
512517
client = Saml2Client(conf, state_cache=state,
513-
identity_cache=IdentityCache(request.session))
518+
identity_cache=IdentityCache(saml_session))
514519

515520
if 'SAMLResponse' in data: # we started the logout
516521
logger.debug('Receiving a logout response from the IdP')
@@ -520,7 +525,7 @@ def do_logout_service(request, data, binding, config_loader_path=None, next_page
520525

521526
elif 'SAMLRequest' in data: # logout started by the IdP
522527
logger.debug('Receiving a logout request from the IdP')
523-
subject_id = _get_subject_id(request.session) if hasattr(request, 'session') else None
528+
subject_id = _get_subject_id(saml_session)
524529

525530
if subject_id is None:
526531
logger.warning(

0 commit comments

Comments
 (0)