Skip to content

Commit 3624de6

Browse files
author
Giuseppe De Marco
authored
Merge pull request #260 from lucyeun-alation/dev
Add assertion param to backed.authenticate and backend.is_authorized
2 parents 0b3dc4a + 45ef435 commit 3624de6

File tree

4 files changed

+60
-9
lines changed

4 files changed

+60
-9
lines changed

djangosaml2/backends.py

+3-3
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ def _get_attribute_value(self, django_field: str, attributes: dict, attribute_ma
106106
'value is missing. Probably the user '
107107
'session is expired.')
108108

109-
def authenticate(self, request, session_info=None, attribute_mapping=None, create_unknown_user=True, **kwargs):
109+
def authenticate(self, request, session_info=None, attribute_mapping=None, create_unknown_user=True, assertion_info=None, **kwargs):
110110
if session_info is None or attribute_mapping is None:
111111
logger.info('Session info or attribute mapping are None')
112112
return None
@@ -121,7 +121,7 @@ def authenticate(self, request, session_info=None, attribute_mapping=None, creat
121121

122122
logger.debug(f'attributes: {attributes}')
123123

124-
if not self.is_authorized(attributes, attribute_mapping, idp_entityid):
124+
if not self.is_authorized(attributes, attribute_mapping, idp_entityid, assertion_info):
125125
logger.error('Request not authorized')
126126
return None
127127

@@ -194,7 +194,7 @@ def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dic
194194
""" Hook to clean or filter attributes from the SAML response. No-op by default. """
195195
return attributes
196196

197-
def is_authorized(self, attributes: dict, attribute_mapping: dict, idp_entityid: str, **kwargs) -> bool:
197+
def is_authorized(self, attributes: dict, attribute_mapping: dict, idp_entityid: str, assertion_info: dict, **kwargs) -> bool:
198198
""" Hook to allow custom authorization policies based on SAML attributes. True by default. """
199199
return True
200200

djangosaml2/views.py

+13-1
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@
4242
StatusNoAuthnContext, StatusRequestDenied,
4343
UnsolicitedResponse)
4444
from saml2.s_utils import UnsupportedBinding
45+
from saml2.saml import SCM_BEARER
4546
from saml2.samlp import AuthnRequest
4647
from saml2.sigver import MissingKey
4748
from saml2.validate import ResponseLifetimeExceed, ToEarly
@@ -56,6 +57,7 @@
5657
get_idp_sso_supported_bindings, get_location,
5758
validate_referral_url)
5859

60+
5961
logger = logging.getLogger('djangosaml2')
6062

6163

@@ -420,6 +422,15 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None):
420422
# authenticate the remote user
421423
session_info = response.session_info()
422424

425+
# assertion_info
426+
assertion = response.assertion
427+
assertion_info = {}
428+
for sc in assertion.subject.subject_confirmation:
429+
if sc.method == SCM_BEARER:
430+
assertion_not_on_or_after = sc.subject_confirmation_data.not_on_or_after
431+
assertion_info = {'assertion_id': assertion.id, 'not_on_or_after': assertion_not_on_or_after}
432+
break
433+
423434
if callable(attribute_mapping):
424435
attribute_mapping = attribute_mapping()
425436
if callable(create_unknown_user):
@@ -430,7 +441,8 @@ def post(self, request, attribute_mapping=None, create_unknown_user=None):
430441
user = auth.authenticate(request=request,
431442
session_info=session_info,
432443
attribute_mapping=attribute_mapping,
433-
create_unknown_user=create_unknown_user)
444+
create_unknown_user=create_unknown_user,
445+
assertion_info=assertion_info)
434446
if user is None:
435447
logger.warning(
436448
"Could not authenticate user received in SAML Assertion. Session info: %s", session_info)

docs/source/contents/setup.rst

+25
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,31 @@ setting::
214214

215215
SAML_CONFIG_LOADER = 'python.path.to.your.callable'
216216

217+
Bearer Assertion Replay Attack Prevention
218+
==================================
219+
In SAML standard doc, section 4.1.4.5 it states
220+
221+
The service provider MUST ensure that bearer assertions are not replayed, by maintaining the set of used ID values for the length of time for which the assertion would be considered valid based on the NotOnOrAfter attribute in the <SubjectConfirmationData>
222+
223+
djangosaml2 provides a hook 'is_authorized' for the SP to store assertion IDs and implement replay prevention with your choice of storage.
224+
::
225+
226+
def is_authorized(self, attributes: dict, attribute_mapping: dict, idp_entityid: str, assertion: object, **kwargs) -> bool:
227+
if not assertion:
228+
return True
229+
230+
# Get your choice of storage
231+
cache_storage = storage.get_cache()
232+
assertion_id = assertion.get('assertion_id')
233+
234+
if cache.get(assertion_id):
235+
logger.warn("Received SAMLResponse assertion has been already used.")
236+
return False
237+
238+
expiration_time = assertion.get('not_on_or_after')
239+
time_delta = isoparse(expiration_time) - datetime.now(timezone.utc)
240+
cache_storage.set(assertion_id, 'True', ex=time_delta)
241+
return True
217242

218243
Users, attributes and account linking
219244
-------------------------------------

tests/testprofiles/tests.py

+19-5
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ def test_extract_user_identifier_params_use_nameid_missing(self):
104104
self.assertEqual(lookup_value, None)
105105

106106
def test_is_authorized(self):
107-
self.assertTrue(self.backend.is_authorized({}, {}, ''))
107+
self.assertTrue(self.backend.is_authorized({}, {}, '', {}))
108108

109109
def test_clean_attributes(self):
110110
attributes = {'random': 'dummy', 'value': 123}
@@ -333,9 +333,9 @@ def test_deprecations(self):
333333
class CustomizedBackend(Saml2Backend):
334334
""" Override the available methods with some customized implementation to test customization
335335
"""
336-
def is_authorized(self, attributes, attribute_mapping, idp_entityid: str, **kwargs):
336+
def is_authorized(self, attributes, attribute_mapping, idp_entityid: str, assertion_info, **kwargs):
337337
''' Allow only staff users from the IDP '''
338-
return attributes.get('is_staff', (None, ))[0] == True
338+
return attributes.get('is_staff', (None, ))[0] == True and assertion_info.get('assertion_id', None) != None
339339

340340
def clean_attributes(self, attributes: dict, idp_entityid: str, **kwargs) -> dict:
341341
''' Keep only age attribute '''
@@ -368,9 +368,15 @@ def test_is_authorized(self):
368368
'cn': ('John', ),
369369
'sn': ('Doe', ),
370370
}
371-
self.assertFalse(self.backend.is_authorized(attributes, attribute_mapping, ''))
371+
assertion_info = {
372+
'assertion_id': None,
373+
'not_on_or_after': None,
374+
}
375+
self.assertFalse(self.backend.is_authorized(attributes, attribute_mapping, '', assertion_info))
372376
attributes['is_staff'] = (True, )
373-
self.assertTrue(self.backend.is_authorized(attributes, attribute_mapping, ''))
377+
self.assertFalse(self.backend.is_authorized(attributes, attribute_mapping, '', assertion_info))
378+
assertion_info['assertion_id'] = 'abcdefg12345'
379+
self.assertTrue(self.backend.is_authorized(attributes, attribute_mapping, '', assertion_info))
374380

375381
def test_clean_attributes(self):
376382
attributes = {'random': 'dummy', 'value': 123, 'age': '28'}
@@ -396,6 +402,10 @@ def test_authenticate(self):
396402
'age': ('28', ),
397403
'is_staff': (True, ),
398404
}
405+
assertion_info = {
406+
'assertion_id': 'abcdefg12345',
407+
'not_on_or_after': '',
408+
}
399409

400410
self.assertEqual(self.user.age, '')
401411
self.assertEqual(self.user.is_staff, False)
@@ -409,6 +419,7 @@ def test_authenticate(self):
409419
None,
410420
session_info={'random': 'content'},
411421
attribute_mapping=attribute_mapping,
422+
assertion_info=assertion_info,
412423
)
413424
self.assertIsNone(user)
414425

@@ -417,6 +428,7 @@ def test_authenticate(self):
417428
None,
418429
session_info={'ava': attributes, 'issuer': 'dummy_entity_id'},
419430
attribute_mapping=attribute_mapping,
431+
assertion_info=assertion_info,
420432
)
421433
self.assertIsNone(user)
422434

@@ -425,6 +437,7 @@ def test_authenticate(self):
425437
None,
426438
session_info={'ava': attributes, 'issuer': 'dummy_entity_id'},
427439
attribute_mapping=attribute_mapping,
440+
assertion_info=assertion_info,
428441
)
429442
self.assertIsNone(user)
430443

@@ -433,6 +446,7 @@ def test_authenticate(self):
433446
None,
434447
session_info={'ava': attributes, 'issuer': 'dummy_entity_id'},
435448
attribute_mapping=attribute_mapping,
449+
assertion_info=assertion_info,
436450
)
437451

438452
self.assertEqual(user, self.user)

0 commit comments

Comments
 (0)