Skip to content

Commit 44a92d3

Browse files
committed
Fix #239 .Check that the response has all of the AuthnContexts that we provided
1 parent c999587 commit 44a92d3

File tree

5 files changed

+64
-0
lines changed

5 files changed

+64
-0
lines changed

src/onelogin/saml2/auth.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ def __init__(self, request_data, old_settings=None, custom_base_path=None):
6262
self.__last_message_id = None
6363
self.__last_assertion_id = None
6464
self.__last_assertion_not_on_or_after = None
65+
self.__last_authn_contexts = []
6566
self.__last_request = None
6667
self.__last_response = None
6768

@@ -107,6 +108,7 @@ def process_response(self, request_id=None):
107108
self.__session_expiration = response.get_session_not_on_or_after()
108109
self.__last_message_id = response.get_id()
109110
self.__last_assertion_id = response.get_assertion_id()
111+
self.__last_authn_contexts = response.get_authn_contexts()
110112
self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after()
111113
self.__authenticated = True
112114

src/onelogin/saml2/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,7 @@ class OneLogin_Saml2_ValidationError(Exception):
112112
INVALID_SIGNATURE = 42
113113
WRONG_NUMBER_OF_SIGNATURES = 43
114114
RESPONSE_EXPIRED = 44
115+
AUTHN_CONTEXT_MISMATCH = 45
115116

116117
def __init__(self, message, code=0, errors=None):
117118
"""

src/onelogin/saml2/response.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,19 @@ def is_valid(self, request_data, request_id=None, raise_exceptions=False):
178178
OneLogin_Saml2_ValidationError.WRONG_NUMBER_OF_AUTHSTATEMENTS
179179
)
180180

181+
# Checks that the response has all of the AuthnContexts that we provided in the request.
182+
# Only check if failOnAuthnContextMismatch is true and requestedAuthnContext is set to a list.
183+
requested_authn_contexts = security.get('requestedAuthnContext', True)
184+
185+
if security.get('failOnAuthnContextMismatch', False) and requested_authn_contexts and requested_authn_contexts is not True:
186+
authn_contexts = self.get_authn_contexts()
187+
unmatched_contexts = set(requested_authn_contexts).difference(authn_contexts)
188+
if unmatched_contexts:
189+
raise OneLogin_Saml2_ValidationError(
190+
'The AuthnContext "%s" didn\'t include requested context "%s"' % (', '.join(authn_contexts), ', '.join(unmatched_contexts)),
191+
OneLogin_Saml2_ValidationError.AUTHN_CONTEXT_MISMATCH
192+
)
193+
181194
# Checks that there is at least one AttributeStatement if required
182195
attribute_statement_nodes = self.__query_assertion('/saml:AttributeStatement')
183196
if security.get('wantAttributeStatement', True) and not attribute_statement_nodes:
@@ -383,6 +396,15 @@ def get_audiences(self):
383396
audience_nodes = self.__query_assertion('/saml:Conditions/saml:AudienceRestriction/saml:Audience')
384397
return [OneLogin_Saml2_Utils.element_text(node) for node in audience_nodes if OneLogin_Saml2_Utils.element_text(node) is not None]
385398

399+
def get_authn_contexts(self):
400+
"""
401+
Gets the authentication contexts
402+
:returns: The authentication classes for the SAML Response
403+
:rtype: list
404+
"""
405+
authn_context_nodes = self.__query_assertion('/saml:AuthnStatement/saml:AuthnContext/saml:AuthnContextClassRef')
406+
return [OneLogin_Saml2_Utils.element_text(node) for node in authn_context_nodes]
407+
386408
def get_issuers(self):
387409
"""
388410
Gets the issuers (from message and from assertion)

src/onelogin/saml2/settings.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,6 +304,7 @@ def __add_default_values(self):
304304
self.__sp.setdefault('privateKey', '')
305305

306306
self.__security.setdefault('requestedAuthnContext', True)
307+
self.__security.setdefault('failOnAuthnContextMismatch', False)
307308

308309
def check_settings(self, settings):
309310
"""

tests/src/OneLogin/saml2_tests/response_test.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -980,6 +980,44 @@ def testIsInValidAudience(self):
980980
self.assertFalse(response_2.is_valid(request_data))
981981
self.assertIn('is not a valid audience for this Response', response_2.get_error())
982982

983+
def testIsInValidAuthenticationContext(self):
984+
"""
985+
Tests that requestedAuthnContext, when set, is compared against the
986+
response AuthnContext, which is what you use for two-factor
987+
authentication. Without this check you can get back a valid response
988+
that didn't complete the two-factor step.
989+
"""
990+
request_data = self.get_request_data()
991+
message = self.file_contents(join(self.data_path, 'responses', 'valid_response.xml.base64'))
992+
two_factor_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken'
993+
password_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password'
994+
settings_dict = self.loadSettingsJSON()
995+
settings_dict['security']['requestedAuthnContext'] = [two_factor_context]
996+
settings_dict['security']['failOnAuthnContextMismatch'] = True
997+
settings_dict['strict'] = True
998+
settings = OneLogin_Saml2_Settings(settings_dict)
999+
1000+
# check that we catch when the contexts don't match
1001+
response = OneLogin_Saml2_Response(settings, message)
1002+
self.assertFalse(response.is_valid(request_data))
1003+
self.assertIn('The AuthnContext "%s" didn\'t include requested context "%s"' % (password_context, two_factor_context), response.get_error())
1004+
1005+
# now drop in the expected AuthnContextClassRef and see that it passes
1006+
original_message = b64decode(message)
1007+
two_factor_message = original_message.replace(password_context, two_factor_context)
1008+
two_factor_message = b64encode(two_factor_message)
1009+
response = OneLogin_Saml2_Response(settings, two_factor_message)
1010+
response.is_valid(request_data)
1011+
# check that we got as far as destination validation, which comes later
1012+
self.assertIn('The response was received at', response.get_error())
1013+
1014+
# with the default setting, check that we succeed with our original context
1015+
settings_dict['security']['requestedAuthnContext'] = True
1016+
settings = OneLogin_Saml2_Settings(settings_dict)
1017+
response = OneLogin_Saml2_Response(settings, message)
1018+
response.is_valid(request_data)
1019+
self.assertIn('The response was received at', response.get_error())
1020+
9831021
def testIsInValidIssuer(self):
9841022
"""
9851023
Tests the is_valid method of the OneLogin_Saml2_Response class

0 commit comments

Comments
 (0)