Skip to content

Commit e70d0cd

Browse files
committed
Be able to get at the auth object the last processed ID (response/assertion) and the last generated ID. Reset errorReason attribute of the auth object after each Process method
1 parent b5ff714 commit e70d0cd

File tree

7 files changed

+210
-4
lines changed

7 files changed

+210
-4
lines changed

README.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,13 @@ The 'x509certMulti' is an array with 2 keys:
889889
- 'encryption' An array with one unique cert that will be used to encrypt data to be sent to the IdP
890890

891891

892+
### Replay attacks ###
893+
894+
In order to avoid reply attacks, you can store the ID of the SAML messages already processed, to avoid processing them twice. Since the Messages expires and will be invalidated due that fact, you don't need to store those IDs longer than the time frame that you currently accepting.
895+
896+
Get the ID of the last processed message/assertion with the get_last_message_id/get_last_assertion_id method of the Auth object.
897+
898+
892899
### Main classes and methods ###
893900

894901
Described below are the main classes and methods that can be invoked from the SAML2 library.
@@ -920,6 +927,9 @@ Main class of OneLogin Python Toolkit
920927
* ***set_strict*** Set the strict mode active/disable.
921928
* ***get_last_request_xml*** Returns the most recently-constructed/processed XML SAML request (AuthNRequest, LogoutRequest)
922929
* ***get_last_response_xml*** Returns the most recently-constructed/processed XML SAML response (SAMLResponse, LogoutResponse). If the SAMLResponse had an encrypted assertion, decrypts it.
930+
* ***get_last_message_id*** The ID of the last Response SAML message processed.
931+
* ***get_last_assertion_id*** The ID of the last assertion processed.
932+
* ***get_last_assertion_not_on_or_after*** The NotOnOrAfter value of the valid SubjectConfirmationData node (if any) of the last assertion processed (is only calculated with strict = true)
923933

924934
#### OneLogin_Saml2_Auth - authn_request.py ####
925935

@@ -948,6 +958,9 @@ SAML 2 Authentication Response class
948958
* ***validate_timestamps*** Verifies that the document is valid according to Conditions Element
949959
* ***get_error*** After execute a validation process, if fails this method returns the cause
950960
* ***get_xml_document*** Returns the SAML Response document (If contains an encrypted assertion, decrypts it).
961+
* ***get_id*** the ID of the response
962+
* ***get_assertion_id*** the ID of the assertion in the response
963+
* ***get_assertion_not_on_or_after*** the NotOnOrAfter value of the valid SubjectConfirmationData if any
951964

952965
#### OneLogin_Saml2_LogoutRequest - logout_request.py ####
953966

src/onelogin/saml2/auth.py

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,9 @@ def __init__(self, request_data, old_settings=None, custom_base_path=None):
5959
self.__errors = []
6060
self.__error_reason = None
6161
self.__last_request_id = None
62+
self.__last_message_id = None
63+
self.__last_assertion_id = None
64+
self.__last_assertion_not_on_or_after = None
6265
self.__last_request = None
6366
self.__last_response = None
6467

@@ -90,6 +93,7 @@ def process_response(self, request_id=None):
9093
:raises: OneLogin_Saml2_Error.SAML_RESPONSE_NOT_FOUND, when a POST with a SAMLResponse is not found
9194
"""
9295
self.__errors = []
96+
self.__error_reason = None
9397

9498
if 'post_data' in self.__request_data and 'SAMLResponse' in self.__request_data['post_data']:
9599
# AuthnResponse -- HTTP_POST Binding
@@ -101,6 +105,9 @@ def process_response(self, request_id=None):
101105
self.__nameid_format = response.get_nameid_format()
102106
self.__session_index = response.get_session_index()
103107
self.__session_expiration = response.get_session_not_on_or_after()
108+
self.__last_message_id = response.get_id()
109+
self.__last_assertion_id = response.get_assertion_id()
110+
self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after()
104111
self.__authenticated = True
105112

106113
else:
@@ -127,6 +134,7 @@ def process_slo(self, keep_local_session=False, request_id=None, delete_session_
127134
:returns: Redirection URL
128135
"""
129136
self.__errors = []
137+
self.__error_reason = None
130138

131139
if 'get_data' in self.__request_data and 'SAMLResponse' in self.__request_data['get_data']:
132140
logout_response = OneLogin_Saml2_Logout_Response(self.__settings, self.__request_data['get_data']['SAMLResponse'])
@@ -136,8 +144,10 @@ def process_slo(self, keep_local_session=False, request_id=None, delete_session_
136144
self.__error_reason = logout_response.get_error()
137145
elif logout_response.get_status() != OneLogin_Saml2_Constants.STATUS_SUCCESS:
138146
self.__errors.append('logout_not_success')
139-
elif not keep_local_session:
140-
OneLogin_Saml2_Utils.delete_local_session(delete_session_cb)
147+
else:
148+
self.__last_message_id = logout_response.id
149+
if not keep_local_session:
150+
OneLogin_Saml2_Utils.delete_local_session(delete_session_cb)
141151

142152
elif 'get_data' in self.__request_data and 'SAMLRequest' in self.__request_data['get_data']:
143153
logout_request = OneLogin_Saml2_Logout_Request(self.__settings, self.__request_data['get_data']['SAMLRequest'])
@@ -150,6 +160,7 @@ def process_slo(self, keep_local_session=False, request_id=None, delete_session_
150160
OneLogin_Saml2_Utils.delete_local_session(delete_session_cb)
151161

152162
in_response_to = logout_request.id
163+
self.__last_message_id = logout_request.id
153164
response_builder = OneLogin_Saml2_Logout_Response(self.__settings)
154165
response_builder.build(in_response_to)
155166
self.__last_response = response_builder.get_xml()
@@ -241,6 +252,13 @@ def get_session_expiration(self):
241252
"""
242253
return self.__session_expiration
243254

255+
def get_last_assertion_not_on_or_after(self):
256+
"""
257+
The NotOnOrAfter value of the valid SubjectConfirmationData node
258+
(if any) of the last assertion processed
259+
"""
260+
return self.__last_assertion_not_on_or_after
261+
244262
def get_errors(self):
245263
"""
246264
Returns a list with code errors if something went wrong
@@ -282,6 +300,20 @@ def get_last_request_id(self):
282300
"""
283301
return self.__last_request_id
284302

303+
def get_last_message_id(self):
304+
"""
305+
:returns: The ID of the last Response SAML message processed.
306+
:rtype: string
307+
"""
308+
return self.__last_message_id
309+
310+
def get_last_assertion_id(self):
311+
"""
312+
:returns: The ID of the last assertion processed.
313+
:rtype: string
314+
"""
315+
return self.__last_assertion_id
316+
285317
def login(self, return_to=None, force_authn=False, is_passive=False, set_nameid_policy=True):
286318
"""
287319
Initiates the SSO process.

src/onelogin/saml2/logout_response.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,10 +40,12 @@ def __init__(self, settings, response=None):
4040
"""
4141
self.__settings = settings
4242
self.__error = None
43+
self.id = None
4344

4445
if response is not None:
4546
self.__logout_response = OneLogin_Saml2_Utils.decode_base64_and_inflate(response)
4647
self.document = parseString(self.__logout_response)
48+
self.id = self.document.documentElement.getAttribute('ID')
4749

4850
def get_issuer(self):
4951
"""

src/onelogin/saml2/response.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ def __init__(self, settings, response):
4343
self.document = fromstring(self.response)
4444
self.decrypted_document = None
4545
self.encrypted = None
46+
self.valid_scd_not_on_or_after = None
4647

4748
# Quick check for the presence of EncryptedAssertion
4849
encrypted_assertion_nodes = self.__query('/samlp:Response/saml:EncryptedAssertion')
@@ -258,6 +259,10 @@ def is_valid(self, request_data, request_id=None, raise_exceptions=False):
258259
parsed_nb = OneLogin_Saml2_Utils.parse_SAML_to_time(nb)
259260
if parsed_nb > OneLogin_Saml2_Utils.now():
260261
continue
262+
263+
if nooa:
264+
self.valid_scd_not_on_or_after = OneLogin_Saml2_Utils.parse_SAML_to_time(nooa)
265+
261266
any_subject_confirmation = True
262267
break
263268

@@ -487,6 +492,12 @@ def get_session_not_on_or_after(self):
487492
not_on_or_after = OneLogin_Saml2_Utils.parse_SAML_to_time(authn_statement_nodes[0].get('SessionNotOnOrAfter'))
488493
return not_on_or_after
489494

495+
def get_assertion_not_on_or_after(self):
496+
"""
497+
Returns the NotOnOrAfter value of the valid SubjectConfirmationData node if any
498+
"""
499+
return self.valid_scd_not_on_or_after
500+
490501
def get_session_index(self):
491502
"""
492503
Gets the SessionIndex from the AuthnStatement
@@ -820,3 +831,22 @@ def get_xml_document(self):
820831
return self.decrypted_document
821832
else:
822833
return self.document
834+
835+
def get_id(self):
836+
"""
837+
:returns: the ID of the response
838+
:rtype: string
839+
"""
840+
return self.document.get('ID', None)
841+
842+
def get_assertion_id(self):
843+
"""
844+
:returns: the ID of the assertion in the response
845+
:rtype: string
846+
"""
847+
if not self.validate_num_assertions():
848+
raise OneLogin_Saml2_ValidationError(
849+
'SAML Response must contain 1 assertion',
850+
OneLogin_Saml2_ValidationError.WRONG_NUMBER_OF_ASSERTIONS
851+
)
852+
return self.__query_assertion('')[0].get('ID', None)

tests/data/responses/valid_response.xml.base64

Lines changed: 1 addition & 1 deletion
Large diffs are not rendered by default.

tests/src/OneLogin/saml2_tests/auth_test.py

Lines changed: 81 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ def testGetSessionExpiration(self):
118118
self.assertIsNone(auth2.get_session_expiration())
119119

120120
auth2.process_response()
121-
self.assertEqual(1392802621, auth2.get_session_expiration())
121+
self.assertEqual(2655106621, auth2.get_session_expiration())
122122

123123
def testGetLastErrorReason(self):
124124
"""
@@ -1002,6 +1002,21 @@ def testBuildResponseSignature(self):
10021002
with self.assertRaisesRegexp(OneLogin_Saml2_Error, "Trying to sign the SAMLResponse but can't load the SP private key"):
10031003
auth2.build_response_signature(message, relay_state)
10041004

1005+
def testGetLastRequestID(self):
1006+
settings_info = self.loadSettingsJSON()
1007+
request_data = self.get_request()
1008+
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info)
1009+
1010+
auth.login()
1011+
id1 = auth.get_last_request_id()
1012+
self.assertNotEqual(id1, None)
1013+
1014+
auth.logout()
1015+
id2 = auth.get_last_request_id()
1016+
self.assertNotEqual(id2, None)
1017+
1018+
self.assertNotEqual(id1, id2)
1019+
10051020
def testGetLastSAMLResponse(self):
10061021
settings = self.loadSettingsJSON()
10071022
message = self.file_contents(join(self.data_path, 'responses', 'signed_message_response.xml.base64'))
@@ -1084,6 +1099,71 @@ def testGetLastLogoutResponse(self):
10841099
auth.process_slo()
10851100
self.assertEqual(response, auth.get_last_response_xml())
10861101

1102+
def testGetInfoFromLastResponseReceived(self):
1103+
"""
1104+
Tests the get_last_message_id, get_last_assertion_id and get_last_assertion_not_on_or_after
1105+
of the OneLogin_Saml2_Auth class
1106+
"""
1107+
settings = self.loadSettingsJSON()
1108+
request_data = self.get_request()
1109+
message = self.file_contents(join(self.data_path, 'responses', 'valid_response.xml.base64'))
1110+
del request_data['get_data']
1111+
request_data['post_data'] = {
1112+
'SAMLResponse': message
1113+
}
1114+
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings)
1115+
1116+
auth.process_response()
1117+
self.assertEqual(auth.get_last_message_id(), 'pfx42be40bf-39c3-77f0-c6ae-8bf2e23a1a2e')
1118+
self.assertEqual(auth.get_last_assertion_id(), 'pfx57dfda60-b211-4cda-0f63-6d5deb69e5bb')
1119+
self.assertIsNone(auth.get_last_assertion_not_on_or_after())
1120+
1121+
# NotOnOrAfter is only calculated with strict = true
1122+
# If invalid, response id and assertion id are not obtained
1123+
1124+
settings['strict'] = True
1125+
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings)
1126+
auth.process_response()
1127+
self.assertNotEqual(len(auth.get_errors()), 0)
1128+
self.assertIsNone(auth.get_last_message_id())
1129+
self.assertIsNone(auth.get_last_assertion_id())
1130+
self.assertIsNone(auth.get_last_assertion_not_on_or_after())
1131+
1132+
request_data['https'] = 'on'
1133+
request_data['http_host'] = 'pitbulk.no-ip.org'
1134+
request_data['script_name'] = '/newonelogin/demo1/index.php?acs'
1135+
auth = OneLogin_Saml2_Auth(request_data, old_settings=settings)
1136+
auth.process_response()
1137+
self.assertEqual(len(auth.get_errors()), 0)
1138+
self.assertEqual(auth.get_last_message_id(), 'pfx42be40bf-39c3-77f0-c6ae-8bf2e23a1a2e')
1139+
self.assertEqual(auth.get_last_assertion_id(), 'pfx57dfda60-b211-4cda-0f63-6d5deb69e5bb')
1140+
self.assertEqual(auth.get_last_assertion_not_on_or_after(), 2671081021)
1141+
1142+
def testGetIdFromLogoutRequest(self):
1143+
"""
1144+
Tests the get_last_message_id of the OneLogin_Saml2_Auth class
1145+
Case Valid Logout request
1146+
"""
1147+
settings = self.loadSettingsJSON()
1148+
request = self.file_contents(join(self.data_path, 'logout_requests', 'logout_request.xml'))
1149+
message = OneLogin_Saml2_Utils.deflate_and_base64_encode(request)
1150+
message_wrapper = {'get_data': {'SAMLRequest': message}}
1151+
auth = OneLogin_Saml2_Auth(message_wrapper, old_settings=settings)
1152+
auth.process_slo()
1153+
self.assertIn(auth.get_last_message_id(), 'ONELOGIN_21584ccdfaca36a145ae990442dcd96bfe60151e')
1154+
1155+
def testGetIdFromLogoutResponse(self):
1156+
"""
1157+
Tests the get_last_message_id of the OneLogin_Saml2_Auth class
1158+
Case Valid Logout response
1159+
"""
1160+
settings = self.loadSettingsJSON()
1161+
response = self.file_contents(join(self.data_path, 'logout_responses', 'logout_response.xml'))
1162+
message = OneLogin_Saml2_Utils.deflate_and_base64_encode(response)
1163+
message_wrapper = {'get_data': {'SAMLResponse': message}}
1164+
auth = OneLogin_Saml2_Auth(message_wrapper, old_settings=settings)
1165+
auth.process_slo()
1166+
self.assertIn(auth.get_last_message_id(), '_f9ee61bd9dbf63606faa9ae3b10548d5b3656fb859')
10871167

10881168
if __name__ == '__main__':
10891169
if is_running_under_teamcity():

tests/src/OneLogin/saml2_tests/response_test.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1408,6 +1408,55 @@ def testStatusCheckBeforeAssertionCheck(self):
14081408
with self.assertRaisesRegexp(OneLogin_Saml2_ValidationError, 'The status code of the Response was not Success, was Responder'):
14091409
response.is_valid(self.get_request_data(), raise_exceptions=True)
14101410

1411+
def testGetId(self):
1412+
"""
1413+
Tests that we can retrieve the ID of the Response
1414+
"""
1415+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
1416+
xml = self.file_contents(join(self.data_path, 'responses', 'signed_message_response.xml.base64'))
1417+
response = OneLogin_Saml2_Response(settings, xml)
1418+
self.assertEqual(response.get_id(), 'pfxc3d2b542-0f7e-8767-8e87-5b0dc6913375')
1419+
1420+
def testGetAssertionId(self):
1421+
"""
1422+
Tests that we can retrieve the ID of the Assertion
1423+
"""
1424+
settings = OneLogin_Saml2_Settings(self.loadSettingsJSON())
1425+
xml = self.file_contents(join(self.data_path, 'responses', 'signed_message_response.xml.base64'))
1426+
response = OneLogin_Saml2_Response(settings, xml)
1427+
self.assertEqual(response.get_assertion_id(), '_cccd6024116641fe48e0ae2c51220d02755f96c98d')
1428+
1429+
def testGetAssertionNotOnOrAfter(self):
1430+
"""
1431+
Tests that we can retrieve the NotOnOrAfter value of
1432+
the valid SubjectConfirmationData
1433+
"""
1434+
settings_data = self.loadSettingsJSON()
1435+
request_data = self.get_request_data()
1436+
settings = OneLogin_Saml2_Settings(settings_data)
1437+
message = self.file_contents(join(self.data_path, 'responses', 'valid_response.xml.base64'))
1438+
response = OneLogin_Saml2_Response(settings, message)
1439+
self.assertIsNone(response.get_assertion_not_on_or_after())
1440+
1441+
response.is_valid(request_data)
1442+
self.assertIsNone(response.get_error())
1443+
self.assertIsNone(response.get_assertion_not_on_or_after())
1444+
1445+
settings_data['strict'] = True
1446+
settings = OneLogin_Saml2_Settings(settings_data)
1447+
response = OneLogin_Saml2_Response(settings, message)
1448+
1449+
response.is_valid(request_data)
1450+
self.assertNotEqual(response.get_error(), None)
1451+
self.assertIsNone(response.get_assertion_not_on_or_after())
1452+
1453+
request_data['https'] = 'on'
1454+
request_data['http_host'] = 'pitbulk.no-ip.org'
1455+
request_data['script_name'] = '/newonelogin/demo1/index.php?acs'
1456+
response.is_valid(request_data)
1457+
self.assertIsNone(response.get_error())
1458+
self.assertEqual(response.get_assertion_not_on_or_after(), 2671081021)
1459+
14111460

14121461
if __name__ == '__main__':
14131462
if is_running_under_teamcity():

0 commit comments

Comments
 (0)