From 5ce2854f62eea378703131f3f8ef40ef45e6cf9f Mon Sep 17 00:00:00 2001 From: Tianhao He Date: Mon, 27 Feb 2017 15:31:51 -0800 Subject: [PATCH] add sso.py to support get bearer/hok token from sso server --- pyVim/sso.py | 1098 ++++++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 2 + 2 files changed, 1100 insertions(+) create mode 100644 pyVim/sso.py diff --git a/pyVim/sso.py b/pyVim/sso.py new file mode 100644 index 0000000..e079861 --- /dev/null +++ b/pyVim/sso.py @@ -0,0 +1,1098 @@ +# VMware vSphere Python SDK +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +A python helper module to do SSO related operations. +""" + +__author__ = 'VMware, Inc.' +__copyright__ = 'Copyright 2012, 2017 VMware, Inc. All rights reserved.' + +#Standard library imports. +import six.moves.http_client +import re +import cgi +import sys +import time +import base64 +import hashlib + +from uuid import uuid4 +from io import BytesIO +from six.moves.urllib.parse import urlparse +#Third-party imports. +from lxml import etree +from OpenSSL import crypto +import ssl + +UTF_8 = 'utf-8' + +def _extract_certificate(cert): + ''' + Extract DER certificate/private key from DER/base64-ed DER/PEM string. + + @type cert: C{str} + @param cert: Certificate/private key in one of three supported formats. + + @rtype: C{str} + @return: Certificate/private key in DER (binary ASN.1) format. + ''' + if not cert: + raise IOError('Empty certificate') + signature = cert[0] + # DER certificate is sequence. ASN.1 sequence is 0x30. + if signature == '\x30': + return cert + # PEM without preamble. Base64-encoded 0x30 is 0x4D. + if signature == '\x4D': + return base64.b64decode(cert) + # PEM with preamble. Starts with '-'. + if signature == '-': + return base64.b64decode(re.sub('-----[A-Z ]*-----', '', cert)) + # Unknown format. + raise IOError('Invalid certificate file format') + + +class SoapException(Exception): + ''' + Exception raised in case of STS request failure. + ''' + def __init__(self, soap_msg, fault_code, fault_string): + ''' + Initializer for SoapException. + + @type soap_msg: C{str} + @param soap_msg: the soap fault XML returned by STS + @type fault_code: C{str} + @param fault_code: The fault code returned by STS. + @type fault_string: C{str} + @param fault_string: The fault string returned by STS. + ''' + self._soap_msg = soap_msg + self._fault_code = fault_code + self._fault_string = fault_string + Exception.__init__(self) + + def __str__(self): + ''' + Returns the string representation of SoapException. + + @rtype: C{str} + @return: string representation of SoapException + ''' + return ("SoapException:\nfaultcode: %(_fault_code)s\n" + "faultstring: %(_fault_string)s\n" + "faultxml: %(_soap_msg)s" % self.__dict__) + + +class SSOHTTPSConnection(six.moves.http_client.HTTPSConnection): + ''' + An HTTPS class that verifies server's certificate on connect. + ''' + def __init__(self, *args, **kwargs): + ''' + Initializer. See httplib.HTTPSConnection for other arguments + than thumbprint and server_cert. + + At least one of thumbprint, server_cert should be provided, + otherwise server certificate is not validated. + + @type thumbprint: C(str) + @param thumbprint: Expected SHA-1 thumbprint of the server + certificate. May be None. + + @type server_cert: C(str) + @param server_cert: File with expected server certificate. + May be None. + ''' + self.server_thumbprint = kwargs.pop('thumbprint') + if self.server_thumbprint is not None: + self.server_thumbprint = re.sub(':', '', + self.server_thumbprint.lower()) + server_cert_path = kwargs.pop('server_cert') + if server_cert_path is not None: + with open(server_cert_path, 'rb') as f: + server_cert = f.read().decode(UTF_8) + self.server_cert = _extract_certificate(server_cert) + else: + self.server_cert = None + six.moves.http_client.HTTPSConnection.__init__(self, *args, **kwargs) + + def _check_cert(self, peerCert): + ''' + Verify that peer certificate matches one we expect. + + @type peerCert: C(str) + @param peerCert: Server certificate in DER format. + + @rtype: boolean + @return: True if peerCert is acceptable. False otherwise. + ''' + if self.server_cert is not None: + if peerCert != self.server_cert: + return False + if self.server_thumbprint is not None: + thumbprint = hashlib.sha1(peerCert).hexdigest().lower() # pylint: disable=E1101 + if thumbprint != self.server_thumbprint: + return False + return True + + def connect(self): + ''' + Connect method: connects to the remote system, and upon + successful connection validates certificate. + + Throws an exception when something is wrong. See + httplib.HTTPSConnection.connect() for details. + ''' + six.moves.http_client.HTTPSConnection.connect(self) + if not self._check_cert(self.sock.getpeercert(True)): + self.sock.close() + self.sock = None + raise IOError("Invalid certificate") + + +class SsoAuthenticator(object): + ''' + A class to handle the transport layer communication between the client and + the STS service. + ''' + + def __init__(self, + sts_url, + sts_cert=None, + thumbprint=None + ): + ''' + Initializer for SsoAuthenticator. + + @type sts_url: C{str} + @param sts_url: URL for the Security Token Service. Usually + obtained by querying Component Manager. + @type sts_cert: C{str} + @param sts_cert: The file with public key of the Security + Token Service. Usually obtained from + Component Manager and written to the file. + @type thumbprint: C{str} + @param thumbprint: The SHA-1 thumbprint of the certificate used + by the Security Token Service. It is same + thumbprint you can pass to pyVmomi SoapAdapter. + ''' + self._sts_cert = sts_cert + self._sts_url = sts_url + self._sts_thumbprint = thumbprint + + def perform_request(self, + soap_message, + public_key=None, + private_key=None, + ssl_context=None): + ''' + Performs a Holder-of-Key SAML token request using the service user's + certificates or a bearer token request using the user credentials. + + @type soap_message: C{str} + @param soap_message: Authentication SOAP request. + @type public_key: C{str} + @param public_key: File containing the public key for the service + user registered with SSO, in PEM format. + @type private_key: C{str} + @param private_key: File containing the private key for the service + user registered with SSO, in PEM format. + @type ssl_context: C{ssl.SSLContext} + @param ssl_context: SSL context describing the various SSL options. + It is only supported in Python 2.7.9 or higher. + @rtype: C{str} + @return: Response received from the STS after the HoK request. + ''' + parsed = urlparse(self._sts_url) + host = parsed.netloc # pylint: disable=E1101 + if hasattr(ssl, '_create_unverified_context'): + # Python 2.7.9 has stronger SSL certificate validation, so we need + # to pass in a context when dealing with self-signed certificates. + webservice = SSOHTTPSConnection(host=host, + key_file=private_key, + cert_file=public_key, + server_cert=self._sts_cert, + thumbprint=self._sts_thumbprint, + context=ssl_context) + else: + # Versions of Python before 2.7.9 don't support + # the context parameter, so don't pass it on. + webservice = SSOHTTPSConnection(host=host, + key_file=private_key, + cert_file=public_key, + server_cert=self._sts_cert, + thumbprint=self._sts_thumbprint) + + webservice.putrequest("POST", parsed.path, skip_host=True) # pylint: disable=E1101 + webservice.putheader("Host", host) + webservice.putheader("User-Agent", "VMware/pyVmomi") + webservice.putheader("Accept", "text/xml, multipart/related") + webservice.putheader("Content-type", "text/xml; charset=\"UTF-8\"") + webservice.putheader("Content-length", "%d" % len(soap_message)) + webservice.putheader("Connection", "keep-alive") + webservice.putheader("SOAPAction", + "http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/Issue") + webservice.endheaders() + webservice.send(soap_message.encode(UTF_8)) + + saml_response = webservice.getresponse() + if saml_response.status != 200: + fault = saml_response.read().decode(UTF_8) + # Best effort at figuring out a SOAP fault. + if saml_response.status == 500 and fault and 'faultcode' in fault: + fault_xml = etree.fromstring(fault) + parsed_fault = fault_xml.xpath("//text()") + if len(parsed_fault) == 2: + raise SoapException(fault, *parsed_fault) + raise Exception("Got response %s: %s\n%s" % + (saml_response.status, saml_response.msg, fault)) + return saml_response.read() + + def get_bearer_saml_assertion(self, + username, + password, + public_key=None, + private_key=None, + request_duration=60, + token_duration=600, + delegatable=False, + renewable=False, + ssl_context=None): + ''' + Extracts the assertion from the Bearer Token received from the Security + Token Service. + + @type username: C{str} + @param username: Username for the user for which bearer token + needs to be requested. + @type password: C{str} + @param password: Password for the user for which bearer token + needs to be requested. + @type public_key: C{str} + @param public_key: File containing the public key for the service + user registered with SSO, in PEM format. + @type private_key: C{str} + @param private_key: File containing the private key for the service + user registered with SSO, in PEM format. + + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is in seconds and the default is 60s. + @type token_duration: C{long} + @param token_duration: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + The default value is False + @type ssl_context: C{ssl.SSLContext} + @param ssl_context: SSL context describing the various SSL options. + It is only supported in Python 2.7.9 or higher. + @rtype: C{str} + @return: The SAML assertion in Unicode. + ''' + request = SecurityTokenRequest(username=username, + password=password, + public_key=public_key, + private_key=private_key, + request_duration=request_duration, + token_duration=token_duration) + soap_message = request.construct_bearer_token_request( + delegatable=delegatable, renewable=renewable) + bearer_token = self.perform_request(soap_message, + public_key, + private_key, + ssl_context) + return etree.tostring( + _extract_element(etree.fromstring(bearer_token), + 'Assertion', + {'saml2': "urn:oasis:names:tc:SAML:2.0:assertion"}), + pretty_print=False).decode(UTF_8) + + def _get_gss_soap_response(self, + binary_token, + request_duration=60, + token_duration=600, + delegatable=False, + renewable=False, + ssl_context=None): + ''' + Extracts the assertion from the Bearer Token received from the Security + Token Service using the binary token generated using either sspi or gss module. + + @type binary_token: C{str} + @param binary_token: The security token in base64 encoded format + + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is in seconds and the default is 60s. + @type token_duration: C{long} + @param token_duration: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + The default value is False + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @type ssl_context: C{ssl.SSLContext} + @param ssl_context: SSL context describing the various SSL options. + It is only supported in Python 2.7.9 or higher. + @rtype: C{str} + @return: The SAML assertion. + ''' + request = SecurityTokenRequest(request_duration=request_duration, + token_duration=token_duration, + gss_binary_token=binary_token) + soap_message = request.construct_bearer_token_request_with_binary_token( + delegatable=delegatable, renewable=renewable) + return self.perform_request(soap_message, + ssl_context=ssl_context) + + def _get_bearer_saml_assertion_win(self, + request_duration=60, + token_duration=600, + delegatable=False, + renewable=False, + ssl_context=None): + ''' + Extracts the assertion from the Bearer Token received from the Security + Token Service using the SSPI module. + + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is in seconds and the default is 60s. + @type token_duration: C{long} + @param token_duration: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + The default value is False + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @type ssl_context: C{ssl.SSLContext} + @param ssl_context: SSL context describing the various SSL options. + It is only supported in Python 2.7.9 or higher. + @rtype: C{str} + @return: The SAML assertion. + ''' + import sspi, win32api + spn = "sts/%s.com" % win32api.GetDomainName() + sspiclient = sspi.ClientAuth("Kerberos", targetspn=spn) + in_buf = None + err = True + # The following will keep running unless we receive a saml token or an error + while True: + err, out_buf = sspiclient.authorize(in_buf) + sectoken = base64.b64encode(out_buf[0].Buffer) + soap_response = self._get_gss_soap_response(sectoken, + request_duration, token_duration, + delegatable, renewable, ssl_context) + et = etree.fromstring(soap_response) + try: + # Check if we have received a challenge token from the server + element = _extract_element(et, + 'BinaryExchange', + {'ns': "http://docs.oasis-open.org/ws-sx/ws-trust/200512"}) + negotiate_token = element.text + out_buf[0].Buffer = base64.b64decode(negotiate_token) + in_buf = out_buf + except KeyError: + # Response does not contain the negotiate token. + # It should contain SAML token then. + saml_token = etree.tostring( + _extract_element( + et, + 'Assertion', + {'saml2': "urn:oasis:names:tc:SAML:2.0:assertion"}), + pretty_print=False).decode(UTF_8) + break + return saml_token + + def _get_bearer_saml_assertion_lin(self, + request_duration=60, + token_duration=600, + delegatable=False, + renewable=False): + ''' + Extracts the assertion from the Bearer Token received from the Security + Token Service using kerberos. + + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is in seconds and the default is 60s. + @type token_duration: C{long} + @param token_duration: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + The default value is False + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @rtype: C{str} + @return: The SAML assertion in Unicode. + ''' + import kerberos, platform + service = 'host@%s' % platform.node() + _, context = kerberos.authGSSClientInit(service, 0) + challenge = '' + # The following will keep running unless we receive a saml token or an error + while True: + # Call GSS step + result = kerberos.authGSSClientStep(context, challenge) + if result < 0: + break + sectoken = kerberos.authGSSClientResponse(context) + soap_response = self._get_gss_soap_response(sectoken, + request_duration, token_duration, delegatable, + renewable) + et = etree.fromstring(soap_response) + try: + # Check if we have received a challenge token from the server + element = _extract_element(et, + 'BinaryExchange', + {'ns': "http://docs.oasis-open.org/ws-sx/ws-trust/200512"}) + negotiate_token = element.text + challenge = negotiate_token + except KeyError: + # Response does not contain the negotiate token. + # It should contain SAML token then. + saml_token = etree.tostring( + _extract_element( + et, + 'Assertion', + {'saml2': "urn:oasis:names:tc:SAML:2.0:assertion"}), + pretty_print=False).decode(UTF_8) + break + return saml_token + + def get_bearer_saml_assertion_gss_api(self, + request_duration=60, + token_duration=600, + delegatable=False, + renewable=False): + ''' + Extracts the assertion from the Bearer Token received from the Security + Token Service using the GSS API. + + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is in seconds and the default is 60s. + @type token_duration: C{long} + @param token_duration: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + The default value is False + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @rtype: C{str} + @return: The SAML assertion. + ''' + if sys.platform == "win32": + saml_token = self._get_bearer_saml_assertion_win(request_duration, + token_duration, delegatable, renewable) + else: + raise Exception("Currently, not supported on this platform") + ## TODO Remove this exception once SSO supports validation of tickets + # generated against host machines + # saml_token = self._get_bearer_saml_assertion_lin(request_duration, token_duration, delegatable) + return saml_token + + def get_hok_saml_assertion(self, + public_key, + private_key, + request_duration=60, + token_duration=600, + act_as_token=None, + delegatable=False, + renewable=False, + ssl_context=None): + ''' + Extracts the assertion from the response received from the Security + Token Service. + + @type public_key: C{str} + @param public_key: File containing the public key for the service + user registered with SSO, in PEM format. + @type private_key: C{str} + @param private_key: File containing the private key for the service + user registered with SSO, in PEM format. + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is in seconds and the default is 60s. + @type token_duration: C{long} + @param token_duration: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + @type act_as_token: C{str} + @param act_as_token: Bearer/Hok token which is delegatable + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @type ssl_context: C{ssl.SSLContext} + @param ssl_context: SSL context describing the various SSL options. + It is only supported in Python 2.7.9 or higher. + @rtype: C{str} + @return: The SAML assertion in Unicode. + ''' + request = SecurityTokenRequest(public_key=public_key, + private_key=private_key, + request_duration=request_duration, + token_duration=token_duration) + soap_message = request.construct_hok_request(delegatable=delegatable, + act_as_token=act_as_token, + renewable=renewable) + hok_token = self.perform_request(soap_message, + public_key, + private_key, + ssl_context) + return etree.tostring( + _extract_element( + etree.fromstring(hok_token), + 'Assertion', + {'saml2': "urn:oasis:names:tc:SAML:2.0:assertion"}), + pretty_print=False).decode(UTF_8) + + +class SecurityTokenRequest(object): + ''' + SecurityTokenRequest class handles the serialization of request to the STS + for a SAML token. + ''' + + #pylint: disable=R0902 + def __init__(self, + username=None, + password=None, + public_key=None, + private_key=None, + request_duration=60, + token_duration=600, + gss_binary_token=None): + ''' + Initializer for the SecurityToken Request class. + + @type username: C{str} + @param username: Username for the user for which bearer token + needs to be requested. + @type password: C{str} + @param password: Password for the user for which bearer token + needs to be requested. + @type public_key: C{str} + @param public_key: The file containing the public key of the + service account requesting the SAML token. + @type private_key: C{str} + @param private_key: The file containing the private key of the + service account requesting the SAML token. + @type request_duration: C{long} + @param request_duration: The duration for which the request is valid. If + the STS receives this request after this + duration, it is assumed to have expired. The + duration is specified in seconds and default is + 60s. + @type token_duration: C{long} + @param token_duraiton: The duration for which the SAML token is issued + for. The duration is specified in seconds and + the default is 600s. + ''' + self._timestamp_id = _generate_id() + self._signature_id = _generate_id() + self._request_id = _generate_id() + self._security_token_id = _generate_id() + current = time.time() + self._created = time.strftime(TIME_FORMAT, + time.gmtime(current)) + self._expires = time.strftime(TIME_FORMAT, + time.gmtime(current + token_duration)) + self._request_expires = time.strftime(TIME_FORMAT, + time.gmtime(current + + request_duration)) + self._timestamp = TIMESTAMP_TEMPLATE % self.__dict__ + self._username = cgi.escape(username) if username else username + self._password = cgi.escape(password) if password else password + self._public_key_file = public_key + self._private_key_file = private_key + self._act_as_token = None + self._renewable = str(False).lower() + self._delegatable = str(False).lower() + self._use_key = "" + self._private_key = None + self._binary_exchange = None + self._public_key = None + if gss_binary_token: + self._binary_exchange = BINARY_EXCHANGE_TEMPLATE % gss_binary_token + #The following are populated later. Set to None here to keep in-line + #with PEP8. + self._binary_security_token = None + self._key_type = None + self._security_token = None + self._signature_text = None + self._signature = None + self._signed_info = None + self._timestamp_digest = None + self._signature_value = None + self._xml_text = None + self._xml = None + self._request_digest = None + + #These will only be populated if requesting an HoK token. + if self._private_key_file: + with open(self._private_key_file) as fp: + self._private_key = fp.read() + + if self._public_key_file: + with open(self._public_key_file) as fp: + self._public_key = fp.read() + + def construct_bearer_token_request(self, delegatable=False, renewable=False): + ''' + Constructs the actual Bearer token SOAP request. + + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @rtype: C{str} + @return: Bearer token SOAP request. + ''' + self._key_type = "http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer" + self._security_token = USERNAME_TOKEN_TEMPLATE % self.__dict__ + self._delegatable = str(delegatable).lower() + self._renewable = str(renewable).lower() + return _canonicalize(REQUEST_TEMPLATE % self.__dict__) + + def construct_bearer_token_request_with_binary_token(self, + delegatable=False, + renewable=False): + ''' + Constructs the actual Bearer token SOAP request using the binary exchange GSS/SSPI token. + + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @rtype: C{str} + @return: Bearer token SOAP request. + ''' + self._key_type = "http://docs.oasis-open.org/ws-sx/ws-trust/200512/Bearer" + self._delegatable = str(delegatable).lower() + self._renewable = str(renewable).lower() + return _canonicalize(GSS_REQUEST_TEMPLATE % self.__dict__) + + def construct_hok_request(self, delegatable=False, act_as_token=None, + renewable=False): + ''' + Constructs the actual HoK token SOAP request. + + @type delegatable: C{boolean} + @param delegatable: Whether the generated token is delegatable or not + @type act_as_token: C{str} + @param act_as_token: Bearer/Hok token which is delegatable + @type renewable: C{boolean} + @param renewable: Whether the generated token is renewable or not + The default value is False + @rtype: C{str} + @return: HoK token SOAP request in Unicode. + ''' + self._binary_security_token = base64.b64encode( + _extract_certificate(self._public_key)).decode(UTF_8) + self._use_key = USE_KEY_TEMPLATE % self.__dict__ + self._security_token = BINARY_SECURITY_TOKEN_TEMPLATE % self.__dict__ + self._key_type = "http://docs.oasis-open.org/ws-sx/ws-trust/200512/PublicKey" + self._renewable = str(renewable).lower() + self._delegatable = str(delegatable).lower() + self._act_as_token = act_as_token + if act_as_token is None: + self._xml_text = _canonicalize(REQUEST_TEMPLATE % self.__dict__) + else: + self._xml_text = ACTAS_REQUEST_TEMPLATE % self.__dict__ + self.sign_request() + return etree.tostring(self._xml, pretty_print=False).decode(UTF_8) + + def sign_request(self): + ''' + Calculates the signature to the header of the SOAP request which can be + used by the STS to verify that the SOAP message originated from a + trusted service. + ''' + base_xml = etree.fromstring(self._xml_text) + request_tree = _extract_element(base_xml, + 'Body', + {'SOAP-ENV': "http://schemas.xmlsoap.org/soap/envelope/"}) + request = _canonicalize(etree.tostring(request_tree)) + request_tree = _extract_element(base_xml, + 'Timestamp', + {'ns3': "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd"}) + timestamp = _canonicalize(etree.tostring(request_tree)) + self._request_digest = _make_hash(request.encode(UTF_8)).decode(UTF_8) # pylint: disable=W0612 + self._timestamp_digest = _make_hash(timestamp.encode(UTF_8)).decode(UTF_8) # pylint: disable=W0612 + self._signed_info = _canonicalize(SIGNED_INFO_TEMPLATE % self.__dict__) + self._signature_value = _sign(self._private_key, self._signed_info).decode(UTF_8) + self._signature_text = _canonicalize(SIGNATURE_TEMPLATE % self.__dict__) + self.embed_signature() + + def embed_signature(self): + ''' + Embeds the signature in to the header of the SOAP request. + ''' + self._xml = etree.fromstring(self._xml_text) + security = _extract_element(self._xml, + 'Security', + {'ns6': "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"}) + self._signature = etree.fromstring(self._signature_text) + security.append(self._signature) + self._xml_text = etree.tostring(self._xml).decode(UTF_8) + + +def add_saml_context(serialized_request, saml_token, private_key_file): + ''' + A helper method provided to sign the outgoing LoginByToken requests with the + HoK token. + + @type serialized_request: C{str} + @param serialized_request: SOAP request which needs to be signed. + @type saml_token: C{str} + @param saml_token: SAML assertion that will be added to the SOAP + request. + @type private_key_file: C{str} + @param private_key_file: Private key of the service user that will be + used to sign the request, in PEM format. + @rtype: C{str} + @return: signed SOAP request in Unicode. + ''' + with open(private_key_file) as fp: + private_key = fp.read() + xml = etree.fromstring(serialized_request) + value_map = {} + value_map['_request_id'] = _generate_id() + request_body = _extract_element(xml, + 'Body', + {'soapenv': "http://schemas.xmlsoap.org/soap/envelope/"}) + request_body.nsmap["wsu"] = "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd" + request_body.set("{http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-utility-1.0.xsd}Id", value_map['_request_id']) + value_map['_request_digest'] = _make_hash( + _canonicalize(etree.tostring(request_body)).encode(UTF_8)).decode(UTF_8) + security = _extract_element(xml, + 'Security', + {'ns6': "http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd"}) + current = time.time() + value_map['_created'] = time.strftime(TIME_FORMAT, + time.gmtime(current)) + value_map['_request_expires'] = time.strftime(TIME_FORMAT, + time.gmtime(current + 600)) + value_map['_timestamp_id'] = _generate_id() + timestamp = _canonicalize(TIMESTAMP_TEMPLATE % value_map) + value_map['_timestamp_digest'] = _make_hash(timestamp.encode()).decode(UTF_8) + + security.append(etree.fromstring(timestamp)) + value_map['_signed_info'] = _canonicalize(SIGNED_INFO_TEMPLATE % value_map) + value_map['_signature_value'] = _sign(private_key, value_map['_signed_info']).decode(UTF_8) + value_map['samlId'] = etree.fromstring(saml_token).get("ID") + signature = etree.fromstring(_canonicalize(REQUEST_SIGNATURE_TEMPLATE % + value_map)) + security.append(signature) + return etree.tostring(xml, pretty_print=False).decode(UTF_8) + + +def _generate_id(): + ''' + An internal helper method to generate UUIDs. + + @rtype: C{str} + @return: UUID + ''' + return "_%s" % uuid4() + + +def _load_private_key(der_key): + ''' + An internal helper to load private key. + + @type der_key: C{str} + @param der_key: The private key, in DER format. + + @rtype: crypto.privatekey + @return: Loaded private key. + ''' + + # OpenSSL 0.9.8 does not handle correctly PKCS8 keys passed in DER format + # (only PKCS1 keys are understood in DER format). + + # Unencrypted PKCS8, or PKCS1 for OpenSSL 1.0.1, PKCS1 for OpenSSL 0.9.8 + try: + return crypto.load_privatekey(crypto.FILETYPE_ASN1, der_key, '') + except (crypto.Error, ValueError): + pass + # Unencrypted PKCS8 for OpenSSL 0.9.8, and PKCS1, just in case... + for key_type in ('PRIVATE KEY', 'RSA PRIVATE KEY'): + try: + return crypto.load_privatekey(crypto.FILETYPE_PEM, + '-----BEGIN ' + key_type + '-----\n' + + base64.encodestring(der_key).decode(UTF_8) + + '-----END ' + key_type + '-----\n', + b'') + except (crypto.Error, ValueError): + pass + # We could try 'ENCRYPTED PRIVATE KEY' here - but we do not know passphrase. + raise + +def _sign(private_key, data): + ''' + An internal helper method to sign the 'data' with the 'private_key'. + + @type private_key: C{str} + @param private_key: The private key used to sign the 'data', in one of + supported formats. + @type data: C{str} + @param data: The data that needs to be signed. + + @rtype: C{str} + @return: Signed string. + ''' + # Convert private key in arbitrary format into DER (DER is binary format + # so we get rid of \n / \r\n differences, and line breaks in PEM). + pkey = _load_private_key(_extract_certificate(private_key)) + return base64.b64encode(crypto.sign(pkey, data, 'sha256')) + + +def _canonicalize(xml_string): + ''' + Given an xml string, canonicalize the string per + U{http://www.w3.org/2001/10/xml-exc-c14n#} + + @type xml_string: C{str} + @param xml_string: The XML string that needs to be canonicalized. + + @rtype: C{str} + @return: Canonicalized string in Unicode. + ''' + parser = etree.XMLParser(remove_blank_text=True) + tree = etree.fromstring(xml_string, parser=parser).getroottree() + string = BytesIO() + tree.write_c14n(string, exclusive=True, with_comments=False) + return string.getvalue().decode(UTF_8) + +def _extract_element(xml, element_name, namespace): + ''' + An internal method provided to extract an element from the given XML. + + @type xml: C{str} + @param xml: The XML string from which the element will be extracted. + @type element_name: C{str} + @param element_name: The element that needs to be extracted from the XML. + @type namespace: dict + @param namespace: A dict containing the namespace of the element to be + extracted. + + @rtype: etree element. + @return: The extracted element. + ''' + assert(len(namespace) == 1) + result = xml.xpath("//%s:%s" % (list(namespace.keys())[0], element_name), + namespaces=namespace) + if result: + return result[0] + else: + raise KeyError("%s does not seem to be present in the XML." % + element_name) + + +def _make_hash(data): + ''' + An internal method to calculate the sha256 hash of the data. + + @type data: C{str} + @param data: The data for which the hash needs to be calculated. + + @rtype: C{str} + @return: Base64 encoded sha256 hash. + ''' + return base64.b64encode(hashlib.sha256(data).digest()) # pylint: disable=E1101 + + +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S.987Z" + +#The SAML token requests usually contain an xmldsig which guarantees that the +#message hasn't been tampered with during the transport. The following +#SIGNED_INFO_TEMPLATE is used to construct the signedinfo part of the signature. +SIGNED_INFO_TEMPLATE = """\ + + + + + + + + +%(_request_digest)s + + + + + + +%(_timestamp_digest)s + + +""" + +#The following template is used as the container for signed info in WS-Trust +#SOAP requests signed with the SAML token. It contains the digest of the +#signed info, signed with the private key of the Solution user and contains a +#reference to the actual SAML token which contains the solution user's public +#key. +REQUEST_SIGNATURE_TEMPLATE = """\ + +%(_signed_info)s +%(_signature_value)s + + +%(samlId)s + + +""" + +#The following template is used as a signed info container for the actual SAML +#token requests requesting a SAML token. It contains the digest of the signed +#info signed with the Service User's private key. +SIGNATURE_TEMPLATE = """\ + +%(_signed_info)s +%(_signature_value)s + + + + + +""" + +#The following template is used to construct the token requests to the STS. +REQUEST_TEMPLATE = """\ + + + +%(_timestamp)s +%(_security_token)s + + + + +urn:oasis:names:tc:SAML:2.0:assertion +http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue + +%(_created)s +%(_expires)s + + +%(_delegatable)s +%(_key_type)s +http://www.w3.org/2001/04/xmldsig-more#rsa-sha256%(_use_key)s + +""" + +GSS_REQUEST_TEMPLATE = """\ + + + +%(_timestamp)s + + + + +urn:oasis:names:tc:SAML:2.0:assertion +http://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue + +%(_created)s +%(_expires)s + + +%(_delegatable)s +%(_key_type)s +http://www.w3.org/2001/04/xmldsig-more#rsa-sha256 +%(_binary_exchange)s +%(_use_key)s + +""" + +#Template container for the service user's public key when requesting an HoK +#token. +BINARY_SECURITY_TOKEN_TEMPLATE = """\ +%(_binary_security_token)s +""" + +#Template container for user's credentials when requesting a bearer token. +USERNAME_TOKEN_TEMPLATE = """\ + +%(_username)s +%(_password)s +""" + +#Template containing the anchor to the signature. +USE_KEY_TEMPLATE = """\ +""" + +#The follwoing template is used to create a timestamp for the various messages. +#The timestamp is used to indicate the duration of the request itself. +TIMESTAMP_TEMPLATE = """\ + +%(_created)s%(_request_expires)s""" + +BINARY_EXCHANGE_TEMPLATE = """\ +%s""" + +ACTAS_REQUEST_TEMPLATE = """%(_created)s%(_request_expires)s%(_binary_security_token)surn:oasis:names:tc:SAML:2.0:assertionhttp://docs.oasis-open.org/ws-sx/ws-trust/200512/Issue%(_created)s%(_expires)s%(_delegatable)s%(_act_as_token)shttp://docs.oasis-open.org/ws-sx/ws-trust/200512/PublicKeyhttp://www.w3.org/2001/04/xmldsig-more#rsa-sha256""" diff --git a/requirements.txt b/requirements.txt index 07d0f3c..d052ad2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ requests>=2.3.0 six>=1.7.3 +lxml +pyOpenSSL >= 0.14 \ No newline at end of file