diff --git a/example.cfg b/example.cfg index b1c586a6..4434d0ea 100644 --- a/example.cfg +++ b/example.cfg @@ -32,3 +32,15 @@ idp_cert_fingerprint = # Full path to the service provider's private server key. private_key_file = +# SAML Artifact resolver URL at idporten's server. +# Hint: This is the element: +# ArtifactResolutionService Binding="urn:oasis:names:tc:SAML:2.0:bindings:SOAP" +# In the metadata-file for ID-porten. +artifact_resolver_url = + +# SAML assertion binding,- e.g. how to get the assertion. Either get it posted +# from idporten's server or use SOAP to fetch the assertion from ID-porten's +# server at the URL defined in "artifact_resolver_url". +# Possible values: HTTP-POST or HTTP-Artifact +# If not defined, the library will default to HTTP-Artifact +sp_assertion_binding = diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py new file mode 100644 index 00000000..ffaa42e2 --- /dev/null +++ b/idporten/saml/ArtifactResolve.py @@ -0,0 +1,155 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 +# +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. +# +""" +Creates an SAML2 ArtifactResolve message. +""" +import uuid + +from datetime import datetime +from lxml.builder import ElementMaker + +from SignableDocument import SignableDocument + + +class ArtifactResolve(SignableDocument): + """Creates an SAML2 ArtifactResolve message.""" + + def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, + _debug=False, **kwargs): + """A class that a SAML2 Artifactresolve-document. + + Keywords argument: + artifact -- The artifact-string recieved from the IDP. + _etree -- Override the default etree-object (default None). + _clock -- Override the default datetime-object (default None). + _uuid -- Override the defualt uuid-generator (default None). + _debug -- Print debug (default False). + issuer -- The name of your application. + + This class should produce an SAML2 ArtifactResolve protocol- + message like this: + + + + + + + + + + + + + + + + + + + + + + + + """ + super(ArtifactResolve, self).__init__( + _node_name='urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve', + _etree=_etree, _debug=_debug) + + if _clock is None: + _clock = datetime.utcnow + if _uuid is None: + _uuid = uuid.uuid4 + + now = _clock() + now = now.replace(microsecond=0) + now_iso = now.isoformat() + ".875Z" + + unique_id = _uuid() + unique_id = unique_id.hex + issuer = kwargs.pop('issuer') + + samlp_maker = ElementMaker( + namespace='urn:oasis:names:tc:SAML:2.0:protocol', + nsmap=dict(saml2p='urn:oasis:names:tc:SAML:2.0:protocol'), + ) + saml_maker = ElementMaker( + namespace='urn:oasis:names:tc:SAML:2.0:assertion', + nsmap=dict(saml2='urn:oasis:names:tc:SAML:2.0:assertion'), + ) + artifact_resolve = samlp_maker.ArtifactResolve( + Version='2.0', + IssueInstant=now_iso, + ID=unique_id, + ) + + saml_issuer = saml_maker.Issuer() + saml_issuer.text = issuer + artifact_resolve.append(saml_issuer) + + # Add Issuer and artifact under signature. + artifact_resolve.append(self._create_signature(unique_id)) + + saml_artifact = samlp_maker.Artifact() + saml_artifact.text = artifact + artifact_resolve.append(saml_artifact) + + self.document = artifact_resolve + + + @staticmethod + def _create_signature(unique_id): + """Creates all XML-elements needed for an XML-signature.""" + signature_maker = ElementMaker( + namespace='http://www.w3.org/2000/09/xmldsig#', + nsmap=dict(ns1='http://www.w3.org/2000/09/xmldsig#') + ) + + signature_elem = signature_maker.Signature() + + signed_info_elem = signature_maker.SignedInfo() + signature_elem.append(signed_info_elem) + + signed_info_elem.append(signature_maker.CanonicalizationMethod( + Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#' + )) + + signed_info_elem.append(signature_maker.SignatureMethod( + Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1' + )) + + reference_elem = signature_maker.Reference( + URI='#' + unique_id + ) + + signed_info_elem.append(reference_elem) + + transforms_elem = signature_maker.Transforms() + reference_elem.append(transforms_elem) + + transforms_elem.append(signature_maker.Transform( + Algorithm='http://www.w3.org/2000/09/xmldsig#enveloped-signature' + )) + transforms_elem.append(signature_maker.Transform( + Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#' + )) + + reference_elem.append(signature_maker.DigestMethod( + Algorithm='http://www.w3.org/2000/09/xmldsig#sha1' + )) + reference_elem.append(signature_maker.DigestValue()) + + signature_elem.append(signature_maker.SignatureValue()) + + key_info_elem = signature_maker.KeyInfo() + key_info_elem.append(signature_maker.X509Data()) + + signature_elem.append(key_info_elem) + return signature_elem + diff --git a/idporten/saml/ArtifactResponse.py b/idporten/saml/ArtifactResponse.py new file mode 100644 index 00000000..426c582f --- /dev/null +++ b/idporten/saml/ArtifactResponse.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 +# +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. +# +""" +Creates an SAML2 ArtifactResponse message. +The soap-envelope is also a part of the response. +""" +import base64 + +from lxml import etree +from datetime import datetime, timedelta + +from SignatureVerifier import SignatureVerifier + +from Response import ResponseValidationError, ResponseNameIDError + + +namespaces = {'samlp': 'urn:oasis:names:tc:SAML:2.0:protocol', + 'saml': 'urn:oasis:names:tc:SAML:2.0:assertion', + 'soap-env': 'http://schemas.xmlsoap.org/soap/envelope/'} + + +class ArtifactResponse(object): + """ + Creates an SAML2 ArtifactResponse message. + The soap-envelope is regarded as part of the response. + """ + + def __init__(self, art_resp, _base64=None, _etree=None): + super(ArtifactResponse, self).__init__() + if _base64 is None: + _base64 = base64 + if _etree is None: + _etree = etree + + self._signature = None + self.decrypted = None + self._decrypted_document = None + self._document = _etree.fromstring(art_resp) + + + def _parse_datetime(self, date_time): + """ + Parse a date as a string in ISO-format. + """ + return datetime.strptime(date_time, '%Y-%m-%dT%H:%M:%SZ') + + + def _get_name_id(self): + """ + Parse out the SAML2 NameID element. + """ + result = self._decrypted_document.xpath( + ('/soap-env:Envelope/soap-env:Body/samlp:ArtifactResponse' + '/samlp:Response/saml:EncryptedAssertion/saml:Assertion/' + 'saml:Subject/saml:NameID'), + namespaces=namespaces, + ) + length = len(result) + if length > 1: + raise ResponseNameIDError( + 'Found more than one name ID' + ) + if length == 0: + raise ResponseNameIDError( + 'Did not find a name ID' + ) + + node = result.pop() + + return node.text.strip() + + + name_id = property( + fget=_get_name_id, + doc=("The value requested in the name_identifier_format, e.g.," + "the user's email address"), + ) + + + def get_session_index(self): + """ + Get the SAML2 session-index. + """ + result = self._decrypted_document.xpath( + ('/soap-env:Envelope/soap-env:Body/samlp:ArtifactResponse' + '/samlp:Response/saml:EncryptedAssertion/saml:Assertion' + '/saml:AuthnStatement/@SessionIndex'), + namespaces=namespaces, + ) + + return result[0] + + + def get_assertion_attribute_value(self, attribute_name): + """ + Get the value of an AssertionAttribute, located in an + Assertion/AttributeStatement/Attribute[@Name=attribute_name + /AttributeValue tag + """ + result = self._document.xpath( + ('/soap-env:Envelope/soap-env:Body/samlp:ArtifactResponse' + '/samlp:Response/saml:Assertion/saml:AttributeStatement' + '/saml:Attribute[@Name="%s"]/saml:AttributeValue' + % attribute_name), namespaces=namespaces) + return [n.text.strip() for n in result] + + + def is_valid(self, idp_cert_filename, private_key_file, + _clock=None, _verifier=None + ): + """ + Verify that the samlp:ArtifactResponse is valid. + Return True if valid, otherwise False. + """ + if _clock is None: + _clock = datetime.utcnow + if _verifier is None: + _verifier = SignatureVerifier(idp_cert_filename, private_key_file) + + conditions = self._document.xpath( + ('/soap-env:Envelope/soap-env:Body/samlp:ArtifactResponse' + '/samlp:Response/saml:Assertion/saml:Conditions'), + namespaces=namespaces, + ) + + now = _clock() + + not_before = None + not_on_or_after = None + for condition in conditions: + not_on_or_after = condition.attrib.get('NotOnOrAfter', None) + not_before = condition.attrib.get('NotBefore', None) + + if not_before is None: + #notbefore condition is not mandatory. If it is not + # specified, use yesterday as not_before condition + not_before = (now-timedelta(1, 0, 0)).strftime('%Y-%m-%dT%H:%M:%SZ') + #TODO: this is in the encrypted part in our case.. + #if not_on_or_after is None: + # raise ResponseConditionError('Did not find NotOnOrAfter condition') + + not_before = self._parse_datetime(not_before) + #not_on_or_after = self._parse_datetime(not_on_or_after) + + if now < not_before: + raise ResponseValidationError( + 'Current time is earlier than NotBefore condition' + ) + #if now >= not_on_or_after: + # raise ResponseValidationError( + # 'Current time is on or after NotOnOrAfter condition' + # ) + + is_valid, decrypted = _verifier.verify_and_decrypt( + self._document, self._signature, + _node_name=('urn:oasis:names:tc:SAML:2.0:protocol:' + 'ArtifactResponse')) + + self.decrypted = decrypted + self._decrypted_document = etree.fromstring(self.decrypted) + return is_valid + + + def get_decrypted_assertion_attribute_value(self, attribute_name): + """ + Get the value of an AssertionAttribute, located in an + Assertion/AttributeStatement/Attribute[@Name=attribute_name/AttributeValue tag + """ + result = self._decrypted_document.xpath( + ('/soap-env:Envelope/soap-env:Body/samlp:ArtifactResponse' + '/samlp:Response/saml:EncryptedAssertion/saml:Assertion' + '/saml:AttributeStatement/saml:Attribute[@Name="%s"]' + '/saml:AttributeValue' % attribute_name), + namespaces=namespaces) + return [n.text.strip() for n in result] + + diff --git a/idporten/saml/AuthRequest.py b/idporten/saml/AuthRequest.py index af3a0164..c804dcfd 100644 --- a/idporten/saml/AuthRequest.py +++ b/idporten/saml/AuthRequest.py @@ -1,16 +1,13 @@ -import zlib -import base64 +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 import uuid -import urllib -import tempfile -import subprocess as subp from datetime import datetime -from lxml import etree from lxml.builder import ElementMaker from SignableRequest import SignableRequest +LEGAL_BINDINGS = ['HTTP-POST', 'HTTP-Artifact'] class AuthRequest(SignableRequest): def __init__(self, @@ -25,17 +22,27 @@ def __init__(self, issuer -- The name of your application. Some identity providers might need this to establish the identity of the service provider requesting the login. name_identifier_format -- The format of the username required by this application. If you need the email address, use "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress". See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 8.3 for other options. Note that the identity provider might not support all options. idp_sso_target_url -- The URL to which the authentication request should be sent. This would be on the identity + sp_assertion_binding -- The assertion-bind type, possible values HTTP_POST or HTTP-Artifact, default HTTP-Artifact. """ + super(AuthRequest, self).__init__() if _clock is None: _clock = datetime.utcnow if _uuid is None: _uuid = uuid.uuid4 - assertion_consumer_service_url = kwargs.pop('assertion_consumer_service_url') + assertion_consumer_service_url = kwargs.pop( + 'assertion_consumer_service_url') + issuer = kwargs.pop('issuer') name_identifier_format = kwargs.pop('name_identifier_format') self.target_url = kwargs.pop('idp_sso_target_url') + # Introduced when ID-porten stopped using HTTP-POST. + assertion_binding = kwargs.get('sp_assertion_binding', '') + if assertion_binding is None or len(assertion_binding.strip()) == 0: + assertion_binding = 'HTTP-Artifact' + if not assertion_binding in LEGAL_BINDINGS: + raise Exception('Illegal binding') now = _clock() # Resolution finer than milliseconds not allowed @@ -57,7 +64,8 @@ def __init__(self, ) authn_request = samlp_maker.AuthnRequest( - ProtocolBinding='urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST', + ProtocolBinding = ('urn:oasis:names:tc:SAML:2.0:bindings:%s' % + assertion_binding), Version='2.0', IssueInstant=now_iso, ID=unique_id, @@ -82,8 +90,9 @@ def __init__(self, ) authn_request.append(request_authn_context) authn_context_class_ref = saml_maker.AuthnContextClassRef() - authn_context_class_ref.text = ('urn:oasis:names:tc:SAML:2.0:ac:classes:' - + 'PasswordProtectedTransport' - ) + authn_context_class_ref.text = ('urn:oasis:names:tc:SAML:2.0:ac:' + 'classes:PasswordProtectedTransport') + request_authn_context.append(authn_context_class_ref) self.document = authn_request + diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py new file mode 100644 index 00000000..aaf14dc9 --- /dev/null +++ b/idporten/saml/HTTPSOpen.py @@ -0,0 +1,105 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 +# +# Copyright(c) 2016 Norwegian University of Science and Technology +# +""" +A class that communicates over HTTPS-connection. +""" + +import httplib +import urlparse + + +class HTTPSOpen(object): + """A class that communicates over HTTPS-connection. The purpose of + this class is to post data to a given location (URL). The default + behaviour is configured to post a SOAP-Envelope. + The connection is regarded as failied if the returned HTTP-status + is different from 200. + """ + + def __init__(self, location_url, send_data, _method='POST', _timeout=30, + _content_type='text/xml; charset=UTF-8', _debug=False): + """ + Open a https connection to an URL. The URL can specify port on + the format https://hostname:port/...; if no port is specified + the default https-port will be used. + + Keyword arguments: + location_url -- The location (URL) to post the data. + send_data -- The data to post, and the data need to be + urlencoded if it is required and the _content_type- + parameter must be set accordingly. + _method -- Default post. + _timeout -- The connect-timeout (default 30 secs). + _content_type -- The content-type of the data + (default text/xml; charset UTF-8). + _debug -- Print debug (default False). + """ + super(HTTPSOpen, self).__init__() + parsed_location = urlparse.urlparse(location_url) + + host_and_port = parsed_location.netloc.split(':') + self.location_host = None + self.location_port = None + if len(host_and_port) == 2: + self.location_host = host_and_port[0] + self.location_port = int(host_and_port[1]) + else: + self.location_host = host_and_port[0] + self.location_port = httplib.HTTPS_PORT + self.location_path = parsed_location.path + if self.location_path is None or len(self.location_path) == 0: + self.location_path = '/' + self.method = _method + self.content_type = _content_type + self.send_data = send_data + self.timeout = _timeout + self.debug_conn = _debug + + + def communicate(self): + """ + Connect to the URL, send request and return the raw response. + If the returned HTTP-status from the server is not equal to 200, + the connection is regarded as failed and this method will return None. + """ + if self.debug_conn: + print 'Connection parameters:' + print ('location_host = %s, location_port = %d, ' + 'location_path = %s, method = %s, content_type = %s, ' + 'timeout = %d, send_data:\n%s' % + (self.location_host, self.location_port, self.location_path, + self.method, self.content_type, self.timeout, self.send_data)) + + conn = httplib.HTTPSConnection(self.location_host, + port=self.location_port, + timeout=self.timeout) + + headers = { + "Host": self.location_host, + "Content-Type": self.content_type, + "Content-Length": len(self.send_data), + } + if self.debug_conn: + print ('Headers:\n%s' % str(headers)) + conn.request(self.method, self.location_path, body=self.send_data, + headers=headers) + + conn_resp = None + http_response = conn.getresponse() + if http_response.status >= httplib.MULTIPLE_CHOICES: + print ('HTTPS-connection failed; status = %d, reason = %s' % + (http_response.status, http_response.reason)) + elif http_response.status > httplib.OK: + print ('Unexpected status; status = %d, reason = %s' % + (http_response.status, http_response.reason)) + elif http_response.status == httplib.OK: + conn_resp = http_response.read() + conn.close() + if self.debug_conn: + print 'Response:' + print conn_resp + return conn_resp + diff --git a/idporten/saml/LogoutRequest.py b/idporten/saml/LogoutRequest.py index 2d76f6d8..7084f0a8 100644 --- a/idporten/saml/LogoutRequest.py +++ b/idporten/saml/LogoutRequest.py @@ -1,9 +1,6 @@ -import zlib -import base64 +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 import uuid -import urllib -import tempfile -import subprocess as subp from datetime import datetime from lxml import etree @@ -25,6 +22,7 @@ def __init__(self, name_identifier_format -- The format of the username required by this application. If you need the email address, use "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress". See http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf section 8.3 for other options. Note that the identity provider might not support all options. idp_sso_target_url -- The URL to which the authentication request should be sent. This would be on the identity """ + super(LogoutRequest, self).__init__() if _clock is None: _clock = datetime.utcnow if _uuid is None: @@ -77,3 +75,4 @@ def __init__(self, self.raw_xml = etree.tostring(logout_request, xml_declaration=True, encoding='UTF-8') + diff --git a/idporten/saml/LogoutResponse.py b/idporten/saml/LogoutResponse.py index 07b25368..e9d7c083 100644 --- a/idporten/saml/LogoutResponse.py +++ b/idporten/saml/LogoutResponse.py @@ -1,13 +1,16 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 import base64 import zlib from lxml import etree -namespaces=dict( +namespaces = dict( samlp='urn:oasis:names:tc:SAML:2.0:protocol', saml='urn:oasis:names:tc:SAML:2.0:assertion', ) + class LogoutResponse(object): def __init__(self, response): """ @@ -33,7 +36,10 @@ def is_success(self): - The document must have decoded, decompressed and parse correctly. - It must have StatusCode == Success. """ - result = self.document.xpath('/samlp:LogoutResponse/samlp:Status/samlp:StatusCode/@Value', - namespaces=namespaces) + result = self.document.xpath( + '/samlp:LogoutResponse/samlp:Status/samlp:StatusCode/@Value', + namespaces=namespaces) + + return (len(result) == 1 and + result[0] == "urn:oasis:names:tc:SAML:2.0:status:Success") - return len(result) == 1 and result[0] == "urn:oasis:names:tc:SAML:2.0:status:Success" diff --git a/idporten/saml/Response.py b/idporten/saml/Response.py index 78eb323a..c00ba8ee 100644 --- a/idporten/saml/Response.py +++ b/idporten/saml/Response.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 import base64 from lxml import etree @@ -5,11 +7,13 @@ from SignatureVerifier import SignatureVerifier -namespaces=dict( + +namespaces = dict( samlp='urn:oasis:names:tc:SAML:2.0:protocol', saml='urn:oasis:names:tc:SAML:2.0:assertion', ) + class ResponseValidationError(Exception): """There was a problem validating the response""" def __init__(self, msg): @@ -53,17 +57,21 @@ def __init__( if _etree is None: _etree = etree + self.decrypted = None + self._decrypted_document = None decoded_response = _base64.b64decode(response) self._document = _etree.fromstring(decoded_response) self._signature = signature - def _parse_datetime(self, dt): - return datetime.strptime(dt, '%Y-%m-%dT%H:%M:%SZ') + def _parse_datetime(self, date_time): + return datetime.strptime(date_time, '%Y-%m-%dT%H:%M:%SZ') + def _get_name_id(self): result = self._decrypted_document.xpath( - '/samlp:Response/saml:EncryptedAssertion/saml:Assertion/saml:Subject/saml:NameID', + ('/samlp:Response/saml:EncryptedAssertion/saml:Assertion' + '/saml:Subject/saml:NameID'), namespaces=namespaces, ) length = len(result) @@ -87,27 +95,35 @@ def _get_name_id(self): def get_session_index(self): result = self._decrypted_document.xpath( - '/samlp:Response/saml:EncryptedAssertion/saml:Assertion/saml:AuthnStatement/@SessionIndex', - namespaces=namespaces, - ) + ('/samlp:Response/saml:EncryptedAssertion/saml:Assertion' + '/saml:AuthnStatement/@SessionIndex'), namespaces=namespaces,) return result[0] - - - def get_assertion_attribute_value(self,attribute_name): + def get_assertion_attribute_value(self, attribute_name): """ - Get the value of an AssertionAttribute, located in an Assertion/AttributeStatement/Attribute[@Name=attribute_name/AttributeValue tag + Get the value of an AssertionAttribute, located in an + Assertion/AttributeStatement/Attribute[@Name=attribute_name + /AttributeValue tag """ - result = self._document.xpath('/samlp:Response/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name="%s"]/saml:AttributeValue'%attribute_name,namespaces=namespaces) + result = self._document.xpath( + ('/samlp:Response/saml:Assertion/saml:AttributeStatement' + '/saml:Attribute[@Name="%s"]/saml:AttributeValue' % + attribute_name), namespaces=namespaces) return [n.text.strip() for n in result] - def get_decrypted_assertion_attribute_value(self,attribute_name): + + def get_decrypted_assertion_attribute_value(self, attribute_name): """ - Get the value of an AssertionAttribute, located in an Assertion/AttributeStatement/Attribute[@Name=attribute_name/AttributeValue tag + Get the value of an AssertionAttribute, located in an + Assertion/AttributeStatement/Attribute[@Name=attribute_name + /AttributeValue tag """ - result = self._decrypted_document.xpath('/samlp:Response/saml:EncryptedAssertion/saml:Assertion/saml:AttributeStatement/saml:Attribute[@Name="%s"]/saml:AttributeValue'%attribute_name,namespaces=namespaces) + result = self._decrypted_document.xpath( + ('/samlp:Response/saml:EncryptedAssertion/saml:Assertion' + '/saml:AttributeStatement/saml:Attribute[@Name="%s"]' + '/saml:AttributeValue' % attribute_name), namespaces=namespaces) return [n.text.strip() for n in result] @@ -142,7 +158,7 @@ def is_valid( if not_before is None: #notbefore condition is not mandatory. If it is not specified, use yesterday as not_before condition - not_before = (now-timedelta(1,0,0)).strftime('%Y-%m-%dT%H:%M:%SZ') + not_before = (now-timedelta(1, 0, 0)).strftime('%Y-%m-%dT%H:%M:%SZ') #TODO: this is in the encrypted part in our case.. #if not_on_or_after is None: # raise ResponseConditionError('Did not find NotOnOrAfter condition') @@ -164,3 +180,4 @@ def is_valid( self.decrypted = decrypted self._decrypted_document = etree.fromstring(self.decrypted) return is_valid + diff --git a/idporten/saml/SOAPEnvelope.py b/idporten/saml/SOAPEnvelope.py new file mode 100644 index 00000000..78111d48 --- /dev/null +++ b/idporten/saml/SOAPEnvelope.py @@ -0,0 +1,65 @@ +# -*- coding: utf-8 -*- +# vim:sw=4:ts=4:et: +# +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. +# +""" +Creates a SOAP-Envelope. +""" + +from lxml import etree +from lxml.builder import ElementMaker + + +class SOAPEnvelopeError(Exception): + """There was a problem with the SOAP-body.""" + + def __init__(self, msg): + """Inherits Exception super-class.""" + super(SOAPEnvelopeError, self).__init__(msg) + self._msg = msg + + + def __str__(self): + """String representation of this exception.""" + return '%s: %s' % (self.__doc__, self._msg) + + +class SOAPEnvelope(object): + """Creates a SOAP-envelope with a string-placeholder as body, + like this: + + + + + %s + + + """ + def __init__(self): + """Creates a SOAP-envelope.""" + super(SOAPEnvelope, self).__init__() + soap_envelope_maker = ElementMaker( + namespace='http://schemas.xmlsoap.org/soap/envelope/', + nsmap=dict(soapp='http://schemas.xmlsoap.org/soap/envelope/') + ) + + soap_envelope = soap_envelope_maker.Envelope() + soap_body = soap_envelope_maker.Body() + + soap_body.text = '%s' + soap_envelope.append(soap_body) + self.envelope = soap_envelope + + + def __str__(self): + """String-representation of this object.""" + return etree.tostring(self.envelope, xml_declaration=True, + pretty_print=True) + + + def __unicode__(self): + """Unicode-representation of this object.""" + return etree.tostring(self.envelope, xml_declaration=True, + encoding='UTF-8', pretty_print=True) + diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py new file mode 100644 index 00000000..fe11c9c7 --- /dev/null +++ b/idporten/saml/SignableDocument.py @@ -0,0 +1,151 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 +# +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. +# +""" +A base class for signing XML-documents. Other classes that create +XML-documents should sub-class this class if they need to be signed. +""" +import subprocess +import platform +import tempfile + +from lxml import etree + + +class SignableDocumentError(Exception): + """There was a problem signing this message""" + + def __init__(self, msg): + """Inherits Exception super-class.""" + super(SignableDocumentError, self).__init__(msg) + self._msg = msg + + + def __str__(self): + """String representation of this exception.""" + return '%s: %s' % (self.__doc__, self._msg) + + +class SignableDocument(object): + """A base class for signing XML-documents. The purpose of this + class is to act as a super-class for classes that need xml-signature(s).""" + + def __init__(self, _node_name=None, _etree=None, _debug=False): + """More or less an empty constructor. + + Keywords arguments: + _node_name -- Element to start the signing (default None). + _etree -- Override the default etree-object (default None). + _debug -- Print debug-messages (default False). + """ + super(SignableDocument, self).__init__() + self.document = None + self.node_name = _node_name + self.debug = _debug + if _etree is None: + self._etree = etree + + + def __str__(self): + """String-representation of this document.""" + return self._etree.tostring(self.document, xml_declaration=True, + pretty_print=True) + + + def __unicode__(self): + """Unicode-string of this document.""" + return self._etree.tostring(self.document, xml_declaration=True, + encoding='UTF-8', pretty_print=True) + + + @staticmethod + def _get_xmlsec_bin(): + """Get the right xmlsec-command depending on OS.""" + xmlsec_bin = 'xmlsec1' + if platform.system() == 'Windows': + xmlsec_bin = 'xmlsec.exe' + return xmlsec_bin + + + def tostring(self, xml_declaration=True, encoding='UTF-8', + pretty_print=False): + """Return the XML-document as a string. + + Keywords arguments: + xml_declaration -- Include xml-declartion as first line (default True). + encoding -- Charset to use for teh returned string (default UTF-8), + pretty_print -- Include linebreaks and intendation (default False). + """ + return self._etree.tostring(self.document, + xml_declaration=xml_declaration, encoding=encoding, + pretty_print=pretty_print) + + + def write_xml_to_file(self, xml_fp): + """Write the XML-document to a given file. + + Keywords arguments: + xml_fp -- An open filehandle. + """ + doc_str = self.tostring(pretty_print=True) + xml_fp.write(doc_str) + xml_fp.flush() + if self.debug: + print "XML:" + print doc_str + xml_fp.seek(0) + + + def sign_document(self, priv_key_file, _node_name=None, + _tempfile=None, _subprocess=None): + """Sign the XML-document and return the signed document + as a string. + + Keyword arguments: + priv_key_file -- File containing the private key to use for signing. + ca_cert_file -- File containing the CA-certificate. + _node_name -- The XML-node where the signing should start. + _tempfile -- Override the default tempfile-object (default None). + _subprocess -- Overrride the default subprocess-object (default None). + + Raises SignableDocumentError if an error occurs.""" + if _tempfile is None: + _tempfile = tempfile + if _subprocess is None: + _subprocess = subprocess + if _node_name is None: + _node_name = self.node_name + + signed_message = None + with _tempfile.NamedTemporaryFile(suffix='.xml', + delete=False) as xml_fp: + self.write_xml_to_file(xml_fp) + + xmlsec_bin = self._get_xmlsec_bin() + cmds = [xmlsec_bin, + '--sign', + '--privkey-pem', + priv_key_file, + '--id-attr:ID'] + + if _node_name: + cmds.append(_node_name) + + cmds.append(xml_fp.name) + + if self.debug: + print "COMMANDS", cmds + proc = subprocess.Popen( + cmds, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + signed_message, err = proc.communicate() + if err: + raise SignableDocumentError(err) + + return signed_message + diff --git a/idporten/saml/SignableRequest.py b/idporten/saml/SignableRequest.py index c7cd9d34..75d46d8a 100644 --- a/idporten/saml/SignableRequest.py +++ b/idporten/saml/SignableRequest.py @@ -1,13 +1,12 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 import zlib import base64 -import uuid import urllib import tempfile import subprocess as subp -from datetime import datetime from lxml import etree -from lxml.builder import ElementMaker def sign_request(urlencoded_request, private_key_file): @@ -38,7 +37,9 @@ def __init__(self): self.document = None self.target_url = None - def get_signed_url(self, private_key_file, _zlib=None, _base64=None, _urllib=None): + + def get_signed_url(self, private_key_file, _zlib=None, _base64=None, + _urllib=None): if _zlib is None: _zlib = zlib if _base64 is None: @@ -46,7 +47,8 @@ def get_signed_url(self, private_key_file, _zlib=None, _base64=None, _urllib=Non if _urllib is None: _urllib = urllib - authn_request_string = etree.tostring(self.document, xml_declaration=True, encoding='UTF-8') + authn_request_string = etree.tostring(self.document, + xml_declaration=True, encoding='UTF-8') compressed_request = _zlib.compress(authn_request_string) @@ -65,3 +67,4 @@ def get_signed_url(self, private_key_file, _zlib=None, _base64=None, _urllib=Non query=urlencoded_request, Signature=signature ) + diff --git a/idporten/saml/SignatureVerifier.py b/idporten/saml/SignatureVerifier.py index 6eea6934..3be85c34 100644 --- a/idporten/saml/SignatureVerifier.py +++ b/idporten/saml/SignatureVerifier.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 import os import subprocess import platform @@ -8,6 +10,7 @@ log = logging.getLogger(__name__) + class SignatureVerifierError(Exception): """There was a problem validating the response""" def __init__(self, msg): @@ -23,11 +26,13 @@ def __init__(self, idp_cert_filename, private_key_file): self.idp_cert_filename = idp_cert_filename self.private_key_file = private_key_file - def verify_and_decrypt(self, document, signature): + def verify_and_decrypt(self, document, signature, _node_name=None): return self.verify(document, signature, self.idp_cert_filename, - self.private_key_file) + self.private_key_file, + _node_name=_node_name) + @staticmethod def _get_xmlsec_bin(): @@ -44,6 +49,7 @@ def verify( signature, idp_cert_filename, private_key_file, + _node_name=None, _etree=None, _tempfile=None, _subprocess=None, @@ -72,12 +78,15 @@ def verify( with _tempfile.NamedTemporaryFile(delete=False) as xml_fp: self.write_xml_to_file(document, xml_fp) - verified = self.verify_xml(xml_fp.name, xmlsec_bin, idp_cert_filename) + verified = self.verify_xml(xml_fp.name, xmlsec_bin, + idp_cert_filename, _node_name=_node_name) if verified: - decrypted = self.decrypt_xml(xml_fp.name, xmlsec_bin, private_key_file) + decrypted = self.decrypt_xml(xml_fp.name, xmlsec_bin, + private_key_file) return verified, decrypted + @staticmethod def _parse_stderr(proc): output = proc.stderr.read() @@ -100,11 +109,13 @@ def _parse_stderr(proc): # Should not happen raise SignatureVerifierError( - 'XMLSec exited with code 0 but did not return OK when verifying the SAML response.' - ) + ('XMLSec exited with code 0 but did not return OK when verifying ' + 'the SAML response.')) + @staticmethod - def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename): + def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename, + _node_name=None): # We cannot use xmlsec python bindings to verify here because # that would require a call to libxml2.xmlAddID. The libxml2 python # bindings do not yet provide this function. @@ -114,12 +125,13 @@ def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename): '--verify', '--pubkey-cert-pem', idp_cert_filename, - '--id-attr', - 'ID', - xml_filename, ] + if _node_name: + cmds.extend(['--id-attr', _node_name]) + + cmds.append(xml_filename) - print "COMMANDS", cmds + # print "COMMANDS", cmds proc = subprocess.Popen( cmds, stderr=subprocess.PIPE, @@ -139,7 +151,7 @@ def decrypt_xml(xml_filename, xmlsec_bin, private_key_file): xml_filename ] - print "COMMANDS", cmds + # print "COMMANDS", cmds proc = subprocess.Popen( cmds, stderr=subprocess.PIPE, @@ -148,12 +160,15 @@ def decrypt_xml(xml_filename, xmlsec_bin, private_key_file): out, err = proc.communicate() return out + @staticmethod def write_xml_to_file(document, xml_fp): doc_str = etree.tostring(document) xml_fp.write('') - xml_fp.write("]>") + xml_fp.write( + "]>") xml_fp.write(doc_str) - print "XML:" - print doc_str + # print "XML:" + # print doc_str xml_fp.seek(0) + diff --git a/idporten/saml/__init__.py b/idporten/saml/__init__.py index 0cc4ad8e..3f5b8a0d 100644 --- a/idporten/saml/__init__.py +++ b/idporten/saml/__init__.py @@ -1,3 +1,5 @@ +# -*- coding: utf-8 -*- +# vim: et:ts=4:sw=4:sts=4 from Response import ( Response, ResponseValidationError, @@ -8,3 +10,10 @@ from SignatureVerifier import SignatureVerifier, SignatureVerifierError from LogoutRequest import LogoutRequest from LogoutResponse import LogoutResponse + +from ArtifactResolve import ArtifactResolve +from ArtifactResponse import ArtifactResponse +from HTTPSOpen import HTTPSOpen +from SignableDocument import SignableDocument +from SignableRequest import SignableRequest +from SOAPEnvelope import SOAPEnvelope diff --git a/setup.py b/setup.py index e049de3e..9660dd86 100644 --- a/setup.py +++ b/setup.py @@ -40,9 +40,11 @@ def run(self): 'nose >= 0.10.4', ] +print find_packages() + setup( name='idporten.saml', - version='0.0.1', + version='0.0.4', description="Python client library for ID-porten SAML Version 2.0", packages = find_packages(), namespace_packages = ['idporten'], @@ -53,7 +55,7 @@ def run(self): cmdclass={ 'example': ExampleCommand }, - author='Kristian Bendiksen', - author_email='kristian.bendiksen@gmail.com', - url='https://github.com/Trondheim-kommune/python-saml-idporten/', + author='Kristian Bendiksen, Trond Kandal', + author_email='kristian.bendiksen@gmail.com, Trond.Kandal@ntnu.no', + url='https://git.it.ntnu.no/projects/BAS/repos/python-saml-idporten/browse', )