diff --git a/setup.py b/setup.py index f97f0189..53d8ccd1 100644 --- a/setup.py +++ b/setup.py @@ -41,7 +41,8 @@ 'isodate>=0.5.0', 'lxml>=3.3.5', 'xmlsec>=1.0.5', - 'defusedxml==0.6.0' + 'defusedxml==0.6.0', + 'requests>=2.24.0' ], dependency_links=['http://github.com/mehcode/python-xmlsec/tarball/master'], extras_require={ @@ -51,6 +52,7 @@ 'pylint==1.9.4', 'flake8==3.6.0', 'coveralls==1.5.1', + 'responses>=0.12.0' ), }, keywords='saml saml2 xmlsec django flask pyramid python3', diff --git a/src/onelogin/saml2/artifact_resolve.py b/src/onelogin/saml2/artifact_resolve.py new file mode 100644 index 00000000..9ac0e99b --- /dev/null +++ b/src/onelogin/saml2/artifact_resolve.py @@ -0,0 +1,117 @@ +import logging +import requests + +from base64 import b64decode +from hashlib import sha1 + +from onelogin.saml2.utils import OneLogin_Saml2_Utils +from onelogin.saml2.xml_templates import OneLogin_Saml2_Templates +from onelogin.saml2.constants import OneLogin_Saml2_Constants + +from .errors import OneLogin_Saml2_ValidationError + + +logger = logging.getLogger(__name__) + + +def parse_saml2_artifact(artifact): + # + # SAMLBind - See 3.6.4 Artifact Format, for SAMLart format. + # + decoded = b64decode(artifact) + type_code = b'\x00\x04' + + if decoded[:2] != type_code: + raise OneLogin_Saml2_ValidationError( + "The received Artifact does not have the correct header.", + OneLogin_Saml2_ValidationError.WRONG_ARTIFACT_FORMAT + ) + + index = str(int.from_bytes(decoded[2:4], byteorder="big")) + sha1_entity_id = decoded[4:24] + message_handle = decoded[24:44] + + return index, sha1_entity_id, message_handle + + +class Artifact_Resolve_Request: + def __init__(self, settings, saml_art): + self.__settings = settings + self.soap_endpoint = self.find_soap_endpoint(saml_art) + self.saml_art = saml_art + + sp_data = self.__settings.get_sp_data() + + uid = OneLogin_Saml2_Utils.generate_unique_id() + self.__id = uid + + issue_instant = OneLogin_Saml2_Utils.parse_time_to_SAML(OneLogin_Saml2_Utils.now()) + + request = OneLogin_Saml2_Templates.ARTIFACT_RESOLVE_REQUEST % \ + { + 'id': uid, + 'issue_instant': issue_instant, + 'entity_id': sp_data['entityId'], + 'artifact': saml_art + } + + self.__artifact_resolve_request = request + + def find_soap_endpoint(self, saml_art): + idp = self.__settings.get_idp_data() + index, sha1_entity_id, message_handle = parse_saml2_artifact(saml_art) + + if sha1_entity_id != sha1(idp['entityId'].encode('utf-8')).digest(): + raise OneLogin_Saml2_ValidationError( + f"The sha1 hash of the entityId returned in the SAML Artifact ({sha1_entity_id})" + f"does not match the sha1 hash of the configured entityId ({idp['entityId']})" + ) + + for ars_node in idp['artifactResolutionService']: + if ars_node['binding'] != "urn:oasis:names:tc:SAML:2.0:bindings:SOAP": + continue + if ars_node['index'] == index: + return ars_node + + return None + + def get_soap_request(self): + request = OneLogin_Saml2_Templates.SOAP_ENVELOPE % \ + { + 'soap_body': self.__artifact_resolve_request + } + + return OneLogin_Saml2_Utils.add_sign( + request, + self.__settings.get_sp_key(), self.__settings.get_sp_cert(), + sign_algorithm=OneLogin_Saml2_Constants.RSA_SHA256, + digest_algorithm=OneLogin_Saml2_Constants.SHA256, + ) + + def send(self): + security_data = self.__settings.get_security_data() + headers = {"content-type": "application/soap+xml"} + url = self.soap_endpoint['url'] + data = self.get_soap_request() + + logger.debug( + "Doing a ArtifactResolve (POST) request to %s with data %s", + url, data + ) + return requests.post( + url=url, + cert=( + security_data['soapClientCert'], + security_data['soapClientKey'], + ), + data=data, + headers=headers, + ) + + def get_id(self): + """ + Returns the ArtifactResolve ID. + :return: ArtifactResolve ID + :rtype: string + """ + return self.__id diff --git a/src/onelogin/saml2/artifact_response.py b/src/onelogin/saml2/artifact_response.py new file mode 100644 index 00000000..813701a9 --- /dev/null +++ b/src/onelogin/saml2/artifact_response.py @@ -0,0 +1,183 @@ +from base64 import b64encode +from defusedxml.lxml import tostring +from onelogin.saml2.constants import OneLogin_Saml2_Constants +from onelogin.saml2.utils import (OneLogin_Saml2_Utils, + OneLogin_Saml2_ValidationError) +from onelogin.saml2.xml_utils import OneLogin_Saml2_XML + + +class Artifact_Response: + def __init__(self, settings, response): + self.__settings = settings + self.__error = None + self.id = None + + self.__artifact_response = response + + soap_envelope = OneLogin_Saml2_XML.to_etree(self.__artifact_response) + + self.document = OneLogin_Saml2_XML.query( + soap_envelope, '/soap:Envelope/soap:Body' + )[0].getchildren()[0] + + self.id = self.document.get('ID', None) + + def get_issuer(self): + """ + Gets the Issuer of the Artifact Response + :return: The Issuer + :rtype: string + """ + issuer = None + issuer_nodes = self.__query('//samlp:ArtifactResponse/saml:Issuer') + if len(issuer_nodes) == 1: + issuer = OneLogin_Saml2_XML.element_text(issuer_nodes[0]) + return issuer + + def get_status(self): + """ + Gets the Status + :return: The Status + :rtype: string + """ + entries = self.__query('//samlp:ArtifactResponse/samlp:Status/samlp:StatusCode') + if len(entries) == 0: + return None + status = entries[0].attrib['Value'] + return status + + def check_status(self): + """ + Check if the status of the response is success or not + + :raises: Exception. If the status is not success + """ + doc = OneLogin_Saml2_XML.query(self.document, '//samlp:ArtifactResponse') + if len(doc) != 1: + raise OneLogin_Saml2_ValidationError( + 'Missing Status on response', + OneLogin_Saml2_ValidationError.MISSING_STATUS + ) + status = OneLogin_Saml2_Utils.get_specific_status( + doc[0], + ) + code = status.get('code', None) + if code and code != OneLogin_Saml2_Constants.STATUS_SUCCESS: + splited_code = code.split(':') + printable_code = splited_code.pop() + status_exception_msg = 'The status code of the ArtifactResponse was not Success, was %s' % printable_code + status_msg = status.get('msg', None) + if status_msg: + status_exception_msg += ' -> ' + status_msg + raise OneLogin_Saml2_ValidationError( + status_exception_msg, + OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS + ) + + def is_valid(self, request_id, raise_exceptions=False): + """ + Determines if the SAML ArtifactResponse is valid + :param request_id: The ID of the ArtifactResolve sent by this SP to the IdP + :type request_id: string + + :param raise_exceptions: Whether to return false on failure or raise an exception + :type raise_exceptions: Boolean + + :return: Returns if the SAML ArtifactResponse is or not valid + :rtype: boolean + """ + self.__error = None + try: + idp_data = self.__settings.get_idp_data() + idp_entity_id = idp_data['entityId'] + + if self.__settings.is_strict(): + res = OneLogin_Saml2_XML.validate_xml(self.document, 'saml-schema-protocol-2.0.xsd', self.__settings.is_debug_active()) + if isinstance(res, str): + raise OneLogin_Saml2_ValidationError( + 'Invalid SAML ArtifactResponse. Not match the saml-schema-protocol-2.0.xsd', + OneLogin_Saml2_ValidationError.INVALID_XML_FORMAT + ) + + security = self.__settings.get_security_data() + + # Check if the InResponseTo of the Artifact Response matches the ID of the Artifact Resolve Request (requestId) if provided + in_response_to = self.get_in_response_to() + if in_response_to and in_response_to != request_id: + raise OneLogin_Saml2_ValidationError( + 'The InResponseTo of the Artifact Response: %s, does not match the ID of the Artifact Resolve Request sent by the SP: %s' % (in_response_to, request_id), + OneLogin_Saml2_ValidationError.WRONG_INRESPONSETO + ) + + self.check_status() + + # Check issuer + issuer = self.get_issuer() + if issuer is not None and issuer != idp_entity_id: + raise OneLogin_Saml2_ValidationError( + 'Invalid issuer in the Logout Response (expected %(idpEntityId)s, got %(issuer)s)' % + { + 'idpEntityId': idp_entity_id, + 'issuer': issuer + }, + OneLogin_Saml2_ValidationError.WRONG_ISSUER + ) + status = self.get_status() + if status != 'urn:oasis:names:tc:SAML:2.0:status:Success': + raise OneLogin_Saml2_ValidationError( + OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS + ) + return True + # pylint: disable=R0801 + except Exception as err: + self.__error = str(err) + debug = self.__settings.is_debug_active() + if debug: + print(err) + if raise_exceptions: + raise + return False + + def __query(self, query): + """ + Extracts a node from the Etree (Logout Response Message) + :param query: Xpath Expression + :type query: string + :return: The queried node + :rtype: Element + """ + return OneLogin_Saml2_XML.query(self.document, query) + + def get_in_response_to(self): + """ + Gets the ID of the ArtifactResolve which this response is in response to + :returns: ID of ArtifactResolve this LogoutResponse is in response to or None if it is not present + :rtype: str + """ + return self.document.get('InResponseTo') + + def get_error(self): + """ + After executing a validation process, if it fails this method returns the cause + """ + return self.__error + + def get_xml(self): + """ + Returns the XML that will be sent as part of the response + or that was received at the SP + :return: XML response body + :rtype: string + """ + return self.__artifact_response + + def get_response_xml(self): + """ + The response is base64 encoded to make it possible to feed + it to the OneLogin_Saml2_Response class. + """ + return b64encode( + tostring( + self.__query('//samlp:ArtifactResponse/samlp:Response')[0] + ) + ) diff --git a/src/onelogin/saml2/auth.py b/src/onelogin/saml2/auth.py index a67e2071..d44f4d78 100644 --- a/src/onelogin/saml2/auth.py +++ b/src/onelogin/saml2/auth.py @@ -11,10 +11,14 @@ """ +import logging import xmlsec + from onelogin.saml2 import compat from onelogin.saml2.authn_request import OneLogin_Saml2_Authn_Request +from onelogin.saml2.artifact_resolve import Artifact_Resolve_Request +from onelogin.saml2.artifact_response import Artifact_Response from onelogin.saml2.constants import OneLogin_Saml2_Constants from onelogin.saml2.logout_request import OneLogin_Saml2_Logout_Request from onelogin.saml2.logout_response import OneLogin_Saml2_Logout_Response @@ -24,6 +28,9 @@ from onelogin.saml2.xmlparser import tostring +logger = logging.getLogger(__name__) + + class OneLogin_Saml2_Auth(object): """ @@ -94,6 +101,62 @@ def set_strict(self, value): assert isinstance(value, bool) self.__settings.set_strict(value) + def artifact_resolve(self, saml_art): + """ + Try to resolve the given artifact + """ + logger.debug( + "Retrieved the SAMLArt %s via the ACS.", saml_art + ) + + resolve_request = Artifact_Resolve_Request(self.__settings, saml_art) + resolve_response = resolve_request.send() + if resolve_response.status_code != 200: + raise OneLogin_Saml2_ValidationError( + "Received a status code {status_code} when trying to resolve the given artifact {saml_art}".format( + status_code=resolve_response.status_code, saml_art=saml_art + ) + ) + + logger.debug( + "Retrieved a ArtifactResponse with content %s", resolve_response.content + ) + + artifact_response = Artifact_Response(self.__settings, resolve_response.content) + if not artifact_response.is_valid(resolve_request.get_id()): + raise OneLogin_Saml2_ValidationError( + "The ArtifactResponse could not be validated due to the following error: {error}".format( + error=artifact_response.get_error() + ) + ) + + saml2_response = OneLogin_Saml2_Response(self.__settings, artifact_response.get_response_xml()) + try: + saml2_response.is_valid(self.__request_data, raise_exceptions=True) + except OneLogin_Saml2_ValidationError as e: + raise OneLogin_Saml2_ValidationError( + "The Response could not be validated due to the following error: {error}".format( + error=saml2_response.get_error() + ), code=e.code + ) + + return saml2_response + + def store_valid_response(self, response): + self.__attributes = response.get_attributes() + self.__friendlyname_attributes = response.get_friendlyname_attributes() + self.__nameid = response.get_nameid() + self.__nameid_format = response.get_nameid_format() + self.__nameid_nq = response.get_nameid_nq() + self.__nameid_spnq = response.get_nameid_spnq() + self.__session_index = response.get_session_index() + self.__session_expiration = response.get_session_not_on_or_after() + self.__last_message_id = response.get_id() + self.__last_assertion_id = response.get_assertion_id() + self.__last_authn_contexts = response.get_authn_contexts() + self.__authenticated = True + self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after() + def process_response(self, request_id=None): """ Process the SAML Response sent by the IdP. @@ -112,24 +175,18 @@ def process_response(self, request_id=None): self.__last_response = response.get_xml_document() if response.is_valid(self.__request_data, request_id): - self.__attributes = response.get_attributes() - self.__friendlyname_attributes = response.get_friendlyname_attributes() - self.__nameid = response.get_nameid() - self.__nameid_format = response.get_nameid_format() - self.__nameid_nq = response.get_nameid_nq() - self.__nameid_spnq = response.get_nameid_spnq() - self.__session_index = response.get_session_index() - self.__session_expiration = response.get_session_not_on_or_after() - self.__last_message_id = response.get_id() - self.__last_assertion_id = response.get_assertion_id() - self.__last_authn_contexts = response.get_authn_contexts() - self.__authenticated = True - self.__last_assertion_not_on_or_after = response.get_assertion_not_on_or_after() - + self.store_valid_response(response) else: self.__errors.append('invalid_response') self.__error_reason = response.get_error() - + elif 'get_data' in self.__request_data and 'SAMLArt' in self.__request_data['get_data']: + try: + response = self.artifact_resolve(self.__request_data['get_data']['SAMLArt']) + except OneLogin_Saml2_ValidationError as e: + self.__errors.append('invalid_response') + self.__error_reason = str(e) + else: + self.store_valid_response(response) else: self.__errors.append('invalid_binding') raise OneLogin_Saml2_Error( diff --git a/src/onelogin/saml2/authn_request.py b/src/onelogin/saml2/authn_request.py index 48ad9d2a..c0ced249 100644 --- a/src/onelogin/saml2/authn_request.py +++ b/src/onelogin/saml2/authn_request.py @@ -124,6 +124,7 @@ def __init__(self, settings, force_authn=False, is_passive=False, set_nameid_pol 'nameid_policy_str': nameid_policy_str, 'requested_authn_context_str': requested_authn_context_str, 'attr_consuming_service_str': attr_consuming_service_str, + 'acs_binding': sp_data['assertionConsumerService'].get('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST') } self.__authn_request = request diff --git a/src/onelogin/saml2/constants.py b/src/onelogin/saml2/constants.py index e85a7fb9..cee93a83 100644 --- a/src/onelogin/saml2/constants.py +++ b/src/onelogin/saml2/constants.py @@ -56,6 +56,7 @@ class OneLogin_Saml2_Constants(object): NS_PREFIX_XSD = 'xsd' NS_PREFIX_XENC = 'xenc' NS_PREFIX_DS = 'ds' + NS_PREFIX_SOAP = 'soap' # Prefix:Namespace Mappings NSMAP = { @@ -63,7 +64,8 @@ class OneLogin_Saml2_Constants(object): NS_PREFIX_SAML: NS_SAML, NS_PREFIX_DS: NS_DS, NS_PREFIX_XENC: NS_XENC, - NS_PREFIX_MD: NS_MD + NS_PREFIX_MD: NS_MD, + NS_PREFIX_SOAP: NS_SOAP } # Bindings @@ -94,6 +96,7 @@ class OneLogin_Saml2_Constants(object): STATUS_NO_PASSIVE = 'urn:oasis:names:tc:SAML:2.0:status:NoPassive' STATUS_PARTIAL_LOGOUT = 'urn:oasis:names:tc:SAML:2.0:status:PartialLogout' STATUS_PROXY_COUNT_EXCEEDED = 'urn:oasis:names:tc:SAML:2.0:status:ProxyCountExceeded' + STATUS_AUTHN_FAILED = 'urn:oasis:names:tc:SAML:2.0:status:AuthnFailed' # Sign & Crypto SHA1 = 'http://www.w3.org/2000/09/xmldsig#sha1' diff --git a/src/onelogin/saml2/errors.py b/src/onelogin/saml2/errors.py index 6a50f9f1..5c95633e 100644 --- a/src/onelogin/saml2/errors.py +++ b/src/onelogin/saml2/errors.py @@ -110,6 +110,7 @@ class OneLogin_Saml2_ValidationError(Exception): WRONG_NUMBER_OF_SIGNATURES = 43 RESPONSE_EXPIRED = 44 AUTHN_CONTEXT_MISMATCH = 45 + STATUS_CODE_AUTHNFAILED = 46 def __init__(self, message, code=0, errors=None): """ @@ -119,7 +120,6 @@ def __init__(self, message, code=0, errors=None): * (int) code. The code error (defined in the error class). """ assert isinstance(code, int) - if errors is not None: message = message % errors diff --git a/src/onelogin/saml2/idp_metadata_parser.py b/src/onelogin/saml2/idp_metadata_parser.py index 2d7eec2c..ae2273a3 100644 --- a/src/onelogin/saml2/idp_metadata_parser.py +++ b/src/onelogin/saml2/idp_metadata_parser.py @@ -126,7 +126,7 @@ def parse( data = {} dom = OneLogin_Saml2_XML.to_etree(idp_metadata) - idp_entity_id = want_authn_requests_signed = idp_name_id_format = idp_sso_url = idp_slo_url = certs = None + idp_entity_id = want_authn_requests_signed = idp_name_id_format = idp_sso_url = idp_slo_url = idp_ars_url = idp_ars_index = certs = None entity_desc_path = '//md:EntityDescriptor' if entity_id: @@ -163,6 +163,13 @@ def parse( if len(slo_nodes) > 0: idp_slo_url = slo_nodes[0].get('Location', None) + ars_nodes = OneLogin_Saml2_XML.query( + idp_descriptor_node, + "./md:ArtifactResolutionService[@Binding='urn:oasis:names:tc:SAML:2.0:bindings:SOAP']" + ) + + + signing_nodes = OneLogin_Saml2_XML.query(idp_descriptor_node, "./md:KeyDescriptor[not(contains(@use, 'encryption'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate") encryption_nodes = OneLogin_Saml2_XML.query(idp_descriptor_node, "./md:KeyDescriptor[not(contains(@use, 'signing'))]/ds:KeyInfo/ds:X509Data/ds:X509Certificate") @@ -192,6 +199,17 @@ def parse( data['idp']['singleLogoutService']['url'] = idp_slo_url data['idp']['singleLogoutService']['binding'] = required_slo_binding + for ars_node in ars_nodes: + idp_ars_url = ars_node.get('Location', None) + idp_ars_index = ars_node.get('index', None) + if idp_ars_url is not None: + ars_list = data['idp'].setdefault('artifactResolutionService', []) + ars_list.append({ + 'url': idp_ars_url, + 'index': idp_ars_index, + 'binding': "urn:oasis:names:tc:SAML:2.0:bindings:SOAP", + }) + if want_authn_requests_signed is not None: data['security'] = {} data['security']['authnRequestsSigned'] = want_authn_requests_signed diff --git a/src/onelogin/saml2/response.py b/src/onelogin/saml2/response.py index 04264919..4fd873a4 100644 --- a/src/onelogin/saml2/response.py +++ b/src/onelogin/saml2/response.py @@ -334,17 +334,24 @@ def check_status(self): :raises: Exception. If the status is not success """ status = OneLogin_Saml2_Utils.get_status(self.document) - code = status.get('code', None) - if code and code != OneLogin_Saml2_Constants.STATUS_SUCCESS: - splited_code = code.split(':') + codes = status.get('codes', None) + if codes and codes[0] != OneLogin_Saml2_Constants.STATUS_SUCCESS: + splited_code = codes[0].split(':') printable_code = splited_code.pop() status_exception_msg = 'The status code of the Response was not Success, was %s' % printable_code status_msg = status.get('msg', None) if status_msg: status_exception_msg += ' -> ' + status_msg + + exception_code = OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS + + # See SAMLCore - 3.2.2.2. AuthnFailed is a second-level status code. + if len(codes) >= 2 and codes[1] == OneLogin_Saml2_Constants.STATUS_AUTHN_FAILED: + exception_code = OneLogin_Saml2_ValidationError.STATUS_CODE_AUTHNFAILED + raise OneLogin_Saml2_ValidationError( status_exception_msg, - OneLogin_Saml2_ValidationError.STATUS_CODE_IS_NOT_SUCCESS + exception_code ) def check_one_condition(self): @@ -666,8 +673,7 @@ def process_signed_elements(self): :returns: The signed elements tag names :rtype: list """ - sign_nodes = self.__query('//ds:Signature') - + sign_nodes = self.__query('//ds:Signature[not(ancestor::saml:Advice)]') signed_elements = [] verified_seis = [] verified_ids = [] diff --git a/src/onelogin/saml2/utils.py b/src/onelogin/saml2/utils.py index 1290033e..1eb8b923 100644 --- a/src/onelogin/saml2/utils.py +++ b/src/onelogin/saml2/utils.py @@ -632,39 +632,52 @@ def generate_name_id(value, sp_nq, sp_format=None, cert=None, debug=False, nq=No else: return OneLogin_Saml2_XML.extract_tag_text(root, "saml:NameID") - @staticmethod - def get_status(dom): + @classmethod + def get_status(cls, dom): """ Gets Status from a Response. :param dom: The Response as XML :type: Document - :returns: The Status, an array with the code and a message. + :returns: The Status, an array with the code and a message. 'code' entry is the + topmost StatusCode, and 'codes' entry contains the StatusCodes in document + order. :rtype: dict """ + doc = OneLogin_Saml2_XML.query(dom, '/samlp:Response') + if len(doc) != 1: + raise OneLogin_Saml2_ValidationError( + 'Missing Status on response', + OneLogin_Saml2_ValidationError.MISSING_STATUS + ) + + return cls.get_specific_status(doc[0]) + + @staticmethod + def get_specific_status(doc): status = {} - status_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status') + status_entry = OneLogin_Saml2_XML.query(doc, './samlp:Status') if len(status_entry) != 1: raise OneLogin_Saml2_ValidationError( 'Missing Status on response', OneLogin_Saml2_ValidationError.MISSING_STATUS ) - code_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status/samlp:StatusCode', status_entry[0]) - if len(code_entry) != 1: + code_entries = OneLogin_Saml2_XML.query(doc, './/samlp:StatusCode', status_entry[0]) + if not code_entries: raise OneLogin_Saml2_ValidationError( 'Missing Status Code on response', OneLogin_Saml2_ValidationError.MISSING_STATUS_CODE ) - code = code_entry[0].values()[0] - status['code'] = code + status['codes'] = [c.get('Value') for c in code_entries] + status['code'] = status['codes'][0] status['msg'] = '' - message_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status/samlp:StatusMessage', status_entry[0]) + message_entry = OneLogin_Saml2_XML.query(doc, './samlp:StatusMessage', status_entry[0]) if len(message_entry) == 0: - subcode_entry = OneLogin_Saml2_XML.query(dom, '/samlp:Response/samlp:Status/samlp:StatusCode/samlp:StatusCode', status_entry[0]) + subcode_entry = OneLogin_Saml2_XML.query(doc, './samlp:StatusCode/samlp:StatusCode', status_entry[0]) if len(subcode_entry) == 1: status['msg'] = subcode_entry[0].values()[0] elif len(message_entry) == 1: diff --git a/src/onelogin/saml2/xml_templates.py b/src/onelogin/saml2/xml_templates.py index 306b1afe..4442dd51 100644 --- a/src/onelogin/saml2/xml_templates.py +++ b/src/onelogin/saml2/xml_templates.py @@ -27,12 +27,31 @@ class OneLogin_Saml2_Templates(object): Version="2.0"%(provider_name)s%(force_authn_str)s%(is_passive_str)s IssueInstant="%(issue_instant)s" Destination="%(destination)s" - ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + ProtocolBinding="%(acs_binding)s" AssertionConsumerServiceURL="%(assertion_url)s"%(attr_consuming_service_str)s> %(entity_id)s%(subject_str)s%(nameid_policy_str)s %(requested_authn_context_str)s """ + SOAP_ENVELOPE = """\ + + +%(soap_body)s + + + """ + + ARTIFACT_RESOLVE_REQUEST = """\ + + %(entity_id)s + %(artifact)s + + """ + LOGOUT_REQUEST = """\ + + + https://idp.com/saml/idp/metadata + + + + + https://idp.com/saml/idp/metadata + + + + + + + + + + + + + + + + + + + + + + + + + https://idp.com/saml/idp/metadata + + s00000000:12345678 + + + + + + + sp.com + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + + + diff --git a/tests/data/artifact_response/artifact_response_invalid.xml b/tests/data/artifact_response/artifact_response_invalid.xml new file mode 100644 index 00000000..c09a587b --- /dev/null +++ b/tests/data/artifact_response/artifact_response_invalid.xml @@ -0,0 +1,56 @@ + + + + https://idp.com/saml/idp/metadata + + + + + https://idp.com/saml/idp/metadata + + + + + + + + + + + + + + + + + + + + + + + + + https://idp.com/saml/idp/metadata + + s00000000:12345678 + + + + + + + sp.com + + + + + + urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport + + + + + + + diff --git a/tests/settings/settings11.json b/tests/settings/settings11.json new file mode 100644 index 00000000..71a1ddd9 --- /dev/null +++ b/tests/settings/settings11.json @@ -0,0 +1,33 @@ +{ + "strict": false, + "debug": false, + "custom_base_path": "../../../tests/data/customPath/", + "sp": { + "entityId": "http://stuff.com/endpoints/metadata.php", + "assertionConsumerService": { + "url": "http://stuff.com/endpoints/endpoints/acs.php", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Artifact" + }, + "NameIDFormat": "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified" + }, + "idp": { + "entityId": "https://idp.com/saml/idp/metadata", + "singleSignOnService": { + "url": "https://idp.com/saml/idp/request_authentication", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" + }, + "artifactResolutionService": [{ + "index": "0", + "url": "https://idp.com/saml/idp/resolve_artifact", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP" + }], + "x509cert": "MIICgTCCAeoCCQCbOlrWDdX7FTANBgkqhkiG9w0BAQUFADCBhDELMAkGA1UEBhMCTk8xGDAWBgNVBAgTD0FuZHJlYXMgU29sYmVyZzEMMAoGA1UEBxMDRm9vMRAwDgYDVQQKEwdVTklORVRUMRgwFgYDVQQDEw9mZWlkZS5lcmxhbmcubm8xITAfBgkqhkiG9w0BCQEWEmFuZHJlYXNAdW5pbmV0dC5ubzAeFw0wNzA2MTUxMjAxMzVaFw0wNzA4MTQxMjAxMzVaMIGEMQswCQYDVQQGEwJOTzEYMBYGA1UECBMPQW5kcmVhcyBTb2xiZXJnMQwwCgYDVQQHEwNGb28xEDAOBgNVBAoTB1VOSU5FVFQxGDAWBgNVBAMTD2ZlaWRlLmVybGFuZy5ubzEhMB8GCSqGSIb3DQEJARYSYW5kcmVhc0B1bmluZXR0Lm5vMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDivbhR7P516x/S3BqKxupQe0LONoliupiBOesCO3SHbDrl3+q9IbfnfmE04rNuMcPsIxB161TdDpIesLCn7c8aPHISKOtPlAeTZSnb8QAu7aRjZq3+PbrP5uW3TcfCGPtKTytHOge/OlJbo078dVhXQ14d1EDwXJW1rRXuUt4C8QIDAQABMA0GCSqGSIb3DQEBBQUAA4GBACDVfp86HObqY+e8BUoWQ9+VMQx1ASDohBjwOsg2WykUqRXF+dLfcUH9dWR63CtZIKFDbStNomPnQz7nbK+onygwBspVEbnHuUihZq3ZUdmumQqCw4Uvs/1Uvq3orOo/WJVhTyvLgFVK2QarQ4/67OZfHd7R+POBXhophSMv1ZOo" + }, + "security": { + "soapClientCert": "abc", + "soapClientKey": "abc", + "authnRequestsSigned": false, + "wantAssertionsSigned": false, + "signMetadata": false + } +} diff --git a/tests/src/OneLogin/saml2_tests/artifact_response_test.py b/tests/src/OneLogin/saml2_tests/artifact_response_test.py new file mode 100644 index 00000000..4be62cd5 --- /dev/null +++ b/tests/src/OneLogin/saml2_tests/artifact_response_test.py @@ -0,0 +1,104 @@ +from base64 import b64decode, b64encode +from lxml import etree +from datetime import datetime +from datetime import timedelta +from freezegun import freeze_time +import json +from os.path import dirname, join, exists +import unittest +from xml.dom.minidom import parseString + +from onelogin.saml2 import compat +from onelogin.saml2.artifact_response import Artifact_Response +from onelogin.saml2.settings import OneLogin_Saml2_Settings +from onelogin.saml2.utils import OneLogin_Saml2_Utils, OneLogin_Saml2_ValidationError +from onelogin.saml2.xml_utils import OneLogin_Saml2_XML + +class Saml2_Artifact_Response_Test(unittest.TestCase): + data_path = join(dirname(dirname(dirname(dirname(__file__)))), 'data') + settings_path = join(dirname(dirname(dirname(dirname(__file__)))), 'settings') + + def loadSettingsJSON(self, name='settings1.json'): + filename = join(self.settings_path, name) + if exists(filename): + stream = open(filename, 'r') + settings = json.load(stream) + stream.close() + return settings + + def file_contents(self, filename): + f = open(filename, 'r') + content = f.read() + f.close() + return content + + def testConstruct(self): + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + settings = OneLogin_Saml2_Settings(self.loadSettingsJSON()) + response_enc = Artifact_Response(settings, response) + + self.assertIsInstance(response_enc, Artifact_Response) + + def testGetResponseXml(self): + response_data = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + response_etree = OneLogin_Saml2_XML.to_etree(response_data) + samlp_response = OneLogin_Saml2_XML.query(response_etree, '//samlp:Response')[0] + expected_data = b64encode(OneLogin_Saml2_XML.to_string(samlp_response)) + + settings = OneLogin_Saml2_Settings(self.loadSettingsJSON()) + response = Artifact_Response(settings, response_data) + + self.assertEqual( + response.get_response_xml(), + expected_data + ) + + def testIsValid(self): + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + json_settings = self.loadSettingsJSON(name='settings11.json') + json_settings['strict'] = True + settings = OneLogin_Saml2_Settings(json_settings) + response = Artifact_Response(settings, response) + + self.assertTrue(response.is_valid( + 'ONELOGIN_5ba93c9db0cff93f52b521d7420e43f6eda2784f' + )) + + def testGetIssuer(self): + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + settings = OneLogin_Saml2_Settings(self.loadSettingsJSON()) + response = Artifact_Response(settings, response) + + self.assertEqual(response.get_issuer(), 'https://idp.com/saml/idp/metadata') + + def testGetStatus(self): + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + settings = OneLogin_Saml2_Settings(self.loadSettingsJSON()) + response = Artifact_Response(settings, response) + + self.assertEqual(response.get_status(), 'urn:oasis:names:tc:SAML:2.0:status:Success') + + def testCheckStatus(self): + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response_invalid.xml' + )) + settings = OneLogin_Saml2_Settings(self.loadSettingsJSON()) + response = Artifact_Response(settings, response) + + with self.assertRaises(OneLogin_Saml2_ValidationError) as context: + response.check_status() + + self.assertEqual( + str(context.exception), + 'The status code of the ArtifactResponse was not Success, was Responder' + ) \ No newline at end of file diff --git a/tests/src/OneLogin/saml2_tests/auth_test.py b/tests/src/OneLogin/saml2_tests/auth_test.py index ea8cf1d3..2d6b0cda 100644 --- a/tests/src/OneLogin/saml2_tests/auth_test.py +++ b/tests/src/OneLogin/saml2_tests/auth_test.py @@ -4,10 +4,13 @@ # MIT License from base64 import b64decode, b64encode +from hashlib import sha1 import json from os.path import dirname, join, exists import unittest +import responses + from onelogin.saml2 import compat from onelogin.saml2.auth import OneLogin_Saml2_Auth from onelogin.saml2.constants import OneLogin_Saml2_Constants @@ -21,6 +24,14 @@ from urlparse import urlparse, parse_qs +def create_example_artifact(endpoint_url, endpoint_index=b"\x00\x00"): + type_code = b"\x00\x04" + source_id = sha1(endpoint_url.encode("utf-8")).digest() + message_handle = b"01234567890123456789" # something random + + return b64encode(type_code + endpoint_index + source_id + message_handle) + + class OneLogin_Saml2_Auth_Test(unittest.TestCase): data_path = join(dirname(dirname(dirname(dirname(__file__)))), 'data') settings_path = join(dirname(dirname(dirname(dirname(__file__)))), 'settings') @@ -1478,3 +1489,116 @@ def testGetIdFromLogoutResponse(self): auth = OneLogin_Saml2_Auth(message_wrapper, old_settings=settings) auth.process_slo() self.assertIn(auth.get_last_message_id(), '_f9ee61bd9dbf63606faa9ae3b10548d5b3656fb859') + + @unittest.mock.patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign') + @responses.activate + def testArtifactResponseSoapRequest(self, mock): + """ + Test that a Artifact Response, makes a SOAP request using the received + saml artifact and returns the response. + """ + mock.return_value = True + + saml_art = create_example_artifact( + "https://idp.com/saml/idp/metadata" + ).decode('utf-8') + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + responses.add( + responses.POST, + "https://idp.com/saml/idp/resolve_artifact", + body=response, + status=200, + ) + + settings_info = self.loadSettingsJSON(name='settings11.json') + request_data = self.get_request() + request_data['get_data'] = { + 'SAMLArt': saml_art + } + auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info) + auth.process_response() + + self.assertEqual( + responses.calls[0].request.url, + 'https://idp.com/saml/idp/resolve_artifact' + ) + self.assertEqual( + responses.calls[0].request.method, + 'POST' + ) + + self.assertIn( + f'{saml_art}', + responses.calls[0].request.body.decode('utf-8') + ) + + @unittest.mock.patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign') + @responses.activate + def testArtifactGetInfoFromLastResponseReceived(self, mock): + mock.return_value = True + + saml_art = create_example_artifact( + "https://idp.com/saml/idp/metadata" + ).decode('utf-8') + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response.xml' + )) + responses.add( + responses.POST, + "https://idp.com/saml/idp/resolve_artifact", + body=response, + status=200, + ) + + settings_info = self.loadSettingsJSON(name='settings11.json') + request_data = self.get_request() + request_data['get_data'] = { + 'SAMLArt': saml_art + } + auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info) + auth.process_response() + + self.assertEqual(len(auth.get_errors()), 0) + self.assertEqual(auth.get_last_message_id(), '_1072ee96') + self.assertEqual(auth.get_last_assertion_id(), '_dc9f70e61c') + self.assertEqual(auth.get_last_assertion_not_on_or_after(), None) + + @unittest.mock.patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.validate_sign') + @responses.activate + def testArtifactErrorCase(self, mock): + mock.return_value = True + + saml_art = create_example_artifact( + "https://idp.com/saml/idp/metadata" + ).decode('utf-8') + response = self.file_contents(join( + self.data_path, 'artifact_response', 'artifact_response_invalid.xml' + )) + responses.add( + responses.POST, + "https://idp.com/saml/idp/resolve_artifact", + body=response, + status=200, + ) + + settings_info = self.loadSettingsJSON(name='settings11.json') + request_data = self.get_request() + request_data['get_data'] = { + 'SAMLArt': saml_art + } + auth = OneLogin_Saml2_Auth(request_data, old_settings=settings_info) + auth.set_strict(True) + auth.process_response() + + self.assertIn( + 'The ArtifactResponse could not be validated due to the following error: ' + 'The InResponseTo of the Artifact Response: ', + auth.get_last_error_reason(), + ) + + self.assertEqual(len(auth.get_errors()), 1) + self.assertIsNone(auth.get_last_message_id()) + self.assertIsNone(auth.get_last_assertion_id()) + self.assertIsNone(auth.get_last_assertion_not_on_or_after()) diff --git a/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py b/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py index 4aa653b5..13451a32 100644 --- a/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py +++ b/tests/src/OneLogin/saml2_tests/idp_metadata_parser_test.py @@ -81,7 +81,12 @@ def testParseRemote(self): "singleSignOnService": { "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - } + }, + "artifactResolutionService": [{ + "url": "https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution", + "index": "2", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP" + }] } } """ @@ -142,7 +147,12 @@ def test_parse_testshib_required_binding_sso_redirect(self): "singleSignOnService": { "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - } + }, + "artifactResolutionService": [{ + "url": "https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution", + "index": "2", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP" + }] } } """ @@ -180,7 +190,12 @@ def test_parse_testshib_required_binding_sso_post(self): "singleSignOnService": { "url": "https://idp.testshib.org/idp/profile/SAML2/POST/SSO", "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" - } + }, + "artifactResolutionService": [{ + "url": "https://idp.testshib.org:8443/idp/profile/SAML2/SOAP/ArtifactResolution", + "index": "2", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:SOAP" + }] } } """