Skip to content

Commit bd2a662

Browse files
authored
Merge pull request SAML-Toolkits#97 from jeffFranklin/master
Check that the response has all of the AuthnContexts that we provided…
2 parents 5001ece + 3da2298 commit bd2a662

File tree

7 files changed

+88
-1
lines changed

7 files changed

+88
-1
lines changed

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,11 +401,13 @@ In addition to the required settings data (idp, sp), extra settings can be defin
401401

402402
// Authentication context.
403403
// Set to false and no AuthContext will be sent in the AuthNRequest,
404-
// Set true or don't present thi parameter and you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
404+
// Set true or don't present this parameter and you will get an AuthContext 'exact' 'urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport'
405405
// Set an array with the possible auth context values: array ('urn:oasis:names:tc:SAML:2.0:ac:classes:Password', 'urn:oasis:names:tc:SAML:2.0:ac:classes:X509'),
406406
"requestedAuthnContext": true,
407407
// Allows the authn comparison parameter to be set, defaults to 'exact' if the setting is not present.
408408
"requestedAuthnContextComparison": "exact",
409+
// Set to true to check that the AuthnContext received matches the one requested.
410+
"failOnAuthnContextMismatch": false,
409411

410412
// In some environment you will need to set how long the published metadata of the Service Provider gonna be valid.
411413
// is possible to not set the 2 following parameters (or set to null) and default values will be set (2 days, 1 week)

src/onelogin/saml2/auth.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ def __init__(self, request_data, old_settings=None, custom_base_path=None):
6363
self.__last_request_id = None
6464
self.__last_message_id = None
6565
self.__last_assertion_id = None
66+
self.__last_authn_contexts = []
6667
self.__last_request = None
6768
self.__last_response = None
6869
self.__last_assertion_not_on_or_after = None
@@ -110,6 +111,7 @@ def process_response(self, request_id=None):
110111
self.__session_expiration = response.get_session_not_on_or_after()
111112
self.__last_message_id = response.get_id()
112113
self.__last_assertion_id = response.get_assertion_id()
114+
self.__last_authn_contexts = response.get_authn_contexts()
113115
self.__authenticated = True
114116
self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after()
115117

@@ -318,6 +320,13 @@ def get_last_assertion_id(self):
318320
"""
319321
return self.__last_assertion_id
320322

323+
def get_last_authn_contexts(self):
324+
"""
325+
:returns: The list of authentication contexts sent in the last SAML resposne.
326+
:rtype: list
327+
"""
328+
return self.__last_authn_contexts
329+
321330
def login(self, return_to=None, force_authn=False, is_passive=False, set_nameid_policy=True):
322331
"""
323332
Initiates the SSO process.

src/onelogin/saml2/errors.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ class OneLogin_Saml2_ValidationError(Exception):
109109
INVALID_SIGNATURE = 42
110110
WRONG_NUMBER_OF_SIGNATURES = 43
111111
RESPONSE_EXPIRED = 44
112+
AUTHN_CONTEXT_MISMATCH = 45
112113

113114
def __init__(self, message, code=0, errors=None):
114115
"""

src/onelogin/saml2/response.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,18 @@ def is_valid(self, request_data, request_id=None, raise_exceptions=False):
161161
OneLogin_Saml2_ValidationError.WRONG_NUMBER_OF_AUTHSTATEMENTS
162162
)
163163

164+
# Checks that the response has all of the AuthnContexts that we provided in the request.
165+
# Only check if failOnAuthnContextMismatch is true and requestedAuthnContext is set to a list.
166+
requested_authn_contexts = security['requestedAuthnContext']
167+
if security['failOnAuthnContextMismatch'] and requested_authn_contexts and requested_authn_contexts is not True:
168+
authn_contexts = self.get_authn_contexts()
169+
unmatched_contexts = set(requested_authn_contexts).difference(authn_contexts)
170+
if unmatched_contexts:
171+
raise OneLogin_Saml2_ValidationError(
172+
'The AuthnContext "%s" didn\'t include requested context "%s"' % (', '.join(authn_contexts), ', '.join(unmatched_contexts)),
173+
OneLogin_Saml2_ValidationError.AUTHN_CONTEXT_MISMATCH
174+
)
175+
164176
# Checks that there is at least one AttributeStatement if required
165177
attribute_statement_nodes = self.__query_assertion('/saml:AttributeStatement')
166178
if security.get('wantAttributeStatement', True) and not attribute_statement_nodes:
@@ -361,6 +373,16 @@ def get_audiences(self):
361373
audience_nodes = self.__query_assertion('/saml:Conditions/saml:AudienceRestriction/saml:Audience')
362374
return [OneLogin_Saml2_XML.element_text(node) for node in audience_nodes if OneLogin_Saml2_XML.element_text(node) is not None]
363375

376+
def get_authn_contexts(self):
377+
"""
378+
Gets the authentication contexts
379+
380+
:returns: The authentication classes for the SAML Response
381+
:rtype: list
382+
"""
383+
authn_context_nodes = self.__query_assertion('/saml:AuthnStatement/saml:AuthnContext/saml:AuthnContextClassRef')
384+
return [OneLogin_Saml2_XML.element_text(node) for node in authn_context_nodes]
385+
364386
def get_issuers(self):
365387
"""
366388
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
@@ -310,6 +310,7 @@ def __add_default_values(self):
310310
self.__sp.setdefault('privateKey', '')
311311

312312
self.__security.setdefault('requestedAuthnContext', True)
313+
self.__security.setdefault('failOnAuthnContextMismatch', False)
313314

314315
def check_settings(self, settings):
315316
"""

tests/src/OneLogin/saml2_tests/auth_test.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,20 @@ def testGetLastAuthnRequest(self):
12311231
)
12321232
self.assertIn(expectedFragment, auth.get_last_request_xml())
12331233

1234+
def testGetLastAuthnContexts(self):
1235+
settings = self.loadSettingsJSON()
1236+
request_data = self.get_request()
1237+
message = self.file_contents(
1238+
join(self.data_path, 'responses', 'valid_response.xml.base64'))
1239+
del request_data['get_data']
1240+
request_data['post_data'] = {
1241+
'SAMLResponse': message
1242+
}
1243+
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings)
1244+
1245+
auth.process_response()
1246+
self.assertEqual(auth.get_last_authn_contexts(), ['urn:oasis:names:tc:SAML:2.0:ac:classes:Password'])
1247+
12341248
def testGetLastLogoutRequest(self):
12351249
settings = self.loadSettingsJSON()
12361250
auth = OneLogin_Saml2_Auth({'http_host': 'localhost', 'script_name': 'thing'}, old_settings=settings)

tests/src/OneLogin/saml2_tests/response_test.py

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

955+
def testIsInValidAuthenticationContext(self):
956+
"""
957+
Tests that requestedAuthnContext, when set, is compared against the
958+
response AuthnContext, which is what you use for two-factor
959+
authentication. Without this check you can get back a valid response
960+
that didn't complete the two-factor step.
961+
"""
962+
request_data = self.get_request_data()
963+
message = self.file_contents(join(self.data_path, 'responses', 'valid_response.xml.base64'))
964+
two_factor_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:TimeSyncToken'
965+
password_context = 'urn:oasis:names:tc:SAML:2.0:ac:classes:Password'
966+
settings_dict = self.loadSettingsJSON()
967+
settings_dict['security']['requestedAuthnContext'] = [two_factor_context]
968+
settings_dict['security']['failOnAuthnContextMismatch'] = True
969+
settings_dict['strict'] = True
970+
settings = OneLogin_Saml2_Settings(settings_dict)
971+
972+
# check that we catch when the contexts don't match
973+
response = OneLogin_Saml2_Response(settings, message)
974+
self.assertFalse(response.is_valid(request_data))
975+
self.assertIn('The AuthnContext "%s" didn\'t include requested context "%s"' % (password_context, two_factor_context), response.get_error())
976+
977+
# now drop in the expected AuthnContextClassRef and see that it passes
978+
original_message = compat.to_string(OneLogin_Saml2_Utils.b64decode(message))
979+
two_factor_message = original_message.replace(password_context, two_factor_context)
980+
two_factor_message = OneLogin_Saml2_Utils.b64encode(two_factor_message)
981+
response = OneLogin_Saml2_Response(settings, two_factor_message)
982+
response.is_valid(request_data)
983+
# check that we got as far as destination validation, which comes later
984+
self.assertIn('The response was received at', response.get_error())
985+
986+
# with the default setting, check that we succeed with our original context
987+
settings_dict['security']['requestedAuthnContext'] = True
988+
settings = OneLogin_Saml2_Settings(settings_dict)
989+
response = OneLogin_Saml2_Response(settings, message)
990+
response.is_valid(request_data)
991+
self.assertIn('The response was received at', response.get_error())
992+
955993
def testIsInValidIssuer(self):
956994
"""
957995
Tests the is_valid method of the OneLogin_Saml2_Response class

0 commit comments

Comments
 (0)