From ffbc49b7ca01d764b8117ebcd2f5c62ea5db0c7d Mon Sep 17 00:00:00 2001 From: kandal Date: Tue, 3 Nov 2015 19:39:18 +0100 Subject: [PATCH 01/27] DATA-557. Intial class for ArtifactResolve. --- idporten/saml/ArtifactResolve.py | 122 +++++++++++++++++++++++++++++++ idporten/saml/mm.py | 9 +++ 2 files changed, 131 insertions(+) create mode 100644 idporten/saml/ArtifactResolve.py create mode 100755 idporten/saml/mm.py diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py new file mode 100644 index 00000000..cf4aedaa --- /dev/null +++ b/idporten/saml/ArtifactResolve.py @@ -0,0 +1,122 @@ +#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 + +from SignableRequest import SignableRequest + + +class ArtifactResolve(object): + def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): + + super(ArtifactResolve, self).__init__() + + if _clock is None: + _clock = datetime.utcnow + if _uuid is None: + _uuid = uuid.uuid4 + + issuer = kwargs.pop('issuer') + + now = _clock() + now = now.replace(microsecond=0) + now_iso = now.isoformat() + ".875Z" #TODO: add better format here + + unique_id = _uuid() + unique_id = unique_id.hex + + 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) + + saml_artifact = saml_maker.Artifact() + saml_artifact.text = artifact + artifact_resolve.append(saml_artifact) + + signature_maker = ElementMaker( + namespace='http://www.w3.org/2000/09/xmldsig#', + ) + + signature_elem = signature_maker.Signature() + + signature_elem.SignedInfo() + + signature_elem.CanonicalizationMethod( + Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#' + ) + + signature_elem.SignatureMethod( + Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1' + ) + + reference_maker = signature_elem.Reference( + ID='#' + unique_id + ) + + + transforms_elems = reference_maker.Transforms() + transforms_elems.Transform( + Algorithm='http://www.w3.org/2000/09/xmldsig#enveloped-signature' + ) + transform_maker = ElementMaker() + transform_canon = transform_maker.Transform( + Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#' + ) + transforms_elems.append(transform_canon) + + reference_maker.DigestMethod( + Algorithm='http://www.w3.org/2000/09/xmldsig#sha1' + ) + reference_maker.DigestValue() + + signature_elem.SignatureValue() + signature_elem.KeyInfo() + + self.document = artifact_resolve + print etree.tostring(self.document, pretty_print=True, encoding='UTF-8') + + +''' + +%s + + + + + + + + + + + + + + + + +%s +''' + diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py new file mode 100755 index 00000000..1fa06e00 --- /dev/null +++ b/idporten/saml/mm.py @@ -0,0 +1,9 @@ +#! /usr/bin/env python +from ArtifactResolve import ArtifactResolve + +def main(*args): + artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja') + +if __name__ == '__main__': + main(sys.argv[1:]) + From 91cf61725090170c660b134454b505337f2cdc7e Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Tue, 3 Nov 2015 21:20:32 +0100 Subject: [PATCH 02/27] DATA-557. Implemented a SAMl2 ArtifactResolve-document. Still missing signing. --- idporten/saml/ArtifactResolve.py | 120 ++++++++++++++++++------------- idporten/saml/mm.py | 5 +- 2 files changed, 75 insertions(+), 50 deletions(-) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index cf4aedaa..d62f8906 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -14,7 +14,33 @@ class ArtifactResolve(object): def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): - + """This should produce an SAML2 ArtifactResolve like this: + + + %s + + + + + + + + + + + + + + + + + + + %s + """ super(ArtifactResolve, self).__init__() if _clock is None: @@ -44,79 +70,75 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): IssueInstant=now_iso, ID=unique_id, ) + saml_issuer = saml_maker.Issuer() saml_issuer.text = issuer artifact_resolve.append(saml_issuer) - saml_artifact = saml_maker.Artifact() + saml_artifact = samlp_maker.Artifact() saml_artifact.text = artifact artifact_resolve.append(saml_artifact) + # Add XML-signature signature_maker = ElementMaker( - namespace='http://www.w3.org/2000/09/xmldsig#', + namespace='http://www.w3.org/2000/09/xmldsig#', + nsmap=dict(ns1='http://www.w3.org/2000/09/xmldsig#') ) signature_elem = signature_maker.Signature() - signature_elem.SignedInfo() + artifact_resolve.append(signature_elem) + + signed_info_elem = signature_maker.SignedInfo() + + signature_elem.append(signed_info_elem) - signature_elem.CanonicalizationMethod( + signed_info_elem.append(signature_maker.CanonicalizationMethod( Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#' - ) + )) - signature_elem.SignatureMethod( + signed_info_elem.append(signature_maker.SignatureMethod( Algorithm='http://www.w3.org/2000/09/xmldsig#rsa-sha1' - ) + )) - reference_maker = signature_elem.Reference( - ID='#' + unique_id + reference_elem = signature_maker.Reference( + URI='#' + unique_id ) + signed_info_elem.append(reference_elem) - transforms_elems = reference_maker.Transforms() - transforms_elems.Transform( + 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' - ) - transform_maker = ElementMaker() - transform_canon = transform_maker.Transform( + )) + transforms_elem.append(signature_maker.Transform( Algorithm='http://www.w3.org/2001/10/xml-exc-c14n#' - ) - transforms_elems.append(transform_canon) + )) - reference_maker.DigestMethod( + reference_elem.append(signature_maker.DigestMethod( Algorithm='http://www.w3.org/2000/09/xmldsig#sha1' - ) - reference_maker.DigestValue() + )) + reference_elem.append(signature_maker.DigestValue()) + + signature_elem.append(signature_maker.SignatureValue()) - signature_elem.SignatureValue() - signature_elem.KeyInfo() + key_info_elem = signature_maker.KeyInfo() + key_info_elem.append(signature_maker.X509Data()) + + signature_elem.append(key_info_elem) self.document = artifact_resolve - print etree.tostring(self.document, pretty_print=True, encoding='UTF-8') - - -''' - -%s - - - - - - - - - - - - - - - - -%s -''' + + + def dump(self, encoding='UTF-8', pretty_print=False): + """Dump the SAML2 ArtifactResolve-document to a string.""" + return etree.tostring(self.document, encoding=encoding, + pretty_print=pretty_print) + + + def signed_artifact_resolve(self, secret_key_file): + """Sign the SAML2 ArtifactResolve-document.""" + raise Exception('Not implemented') diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index 1fa06e00..262506c9 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -1,8 +1,11 @@ #! /usr/bin/env python +import sys from ArtifactResolve import ArtifactResolve def main(*args): - artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja') + artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja', + issuer='dille.ntnu.no') + print artifact_resolve.dump(pretty_print=True) if __name__ == '__main__': main(sys.argv[1:]) From 703dd69e7abb3efa6f3e4e1d91081e4db4d08b23 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Wed, 4 Nov 2015 14:41:32 +0100 Subject: [PATCH 03/27] DATA-557. Refactoring,- sub-classing to make re-usable code. --- idporten/saml/ArtifactResolve.py | 29 ++++------ idporten/saml/SignableMessage.py | 90 ++++++++++++++++++++++++++++++++ idporten/saml/mm.py | 6 ++- 3 files changed, 106 insertions(+), 19 deletions(-) create mode 100644 idporten/saml/SignableMessage.py diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index d62f8906..4338a3d2 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -9,10 +9,10 @@ from lxml import etree from lxml.builder import ElementMaker -from SignableRequest import SignableRequest +from SignableMessage import SignableMessage -class ArtifactResolve(object): +class ArtifactResolve(SignableMessage): def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): """This should produce an SAML2 ArtifactResolve like this: @@ -42,6 +42,7 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): %s """ super(ArtifactResolve, self).__init__() + self.node_ns = 'urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve' if _clock is None: _clock = datetime.utcnow @@ -71,13 +72,6 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): ID=unique_id, ) - saml_issuer = saml_maker.Issuer() - saml_issuer.text = issuer - artifact_resolve.append(saml_issuer) - - saml_artifact = samlp_maker.Artifact() - saml_artifact.text = artifact - artifact_resolve.append(saml_artifact) # Add XML-signature signature_maker = ElementMaker( @@ -129,16 +123,15 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): signature_elem.append(key_info_elem) - self.document = artifact_resolve - + # Add Issuer and artifact under signature. + saml_issuer = saml_maker.Issuer() + saml_issuer.text = issuer + artifact_resolve.append(saml_issuer) - def dump(self, encoding='UTF-8', pretty_print=False): - """Dump the SAML2 ArtifactResolve-document to a string.""" - return etree.tostring(self.document, encoding=encoding, - pretty_print=pretty_print) + saml_artifact = samlp_maker.Artifact() + saml_artifact.text = artifact + artifact_resolve.append(saml_artifact) + self.document = artifact_resolve - def signed_artifact_resolve(self, secret_key_file): - """Sign the SAML2 ArtifactResolve-document.""" - raise Exception('Not implemented') diff --git a/idporten/saml/SignableMessage.py b/idporten/saml/SignableMessage.py new file mode 100644 index 00000000..85104762 --- /dev/null +++ b/idporten/saml/SignableMessage.py @@ -0,0 +1,90 @@ +import subprocess +import platform +import tempfile +import logging + +from lxml import etree + + +class SignableMessageError(Exception): + """There was a problem signing this message""" + def __init__(self, msg): + self._msg = msg + + def __str__(self): + return '%s: %s' % (self.__doc__, self._msg) + + +class SignableMessage(object): + def __init__(self): + super(SignableMessage, self).__init__() + self.document = None + self.node_ns = None + + + @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.""" + return 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.""" + doc_str = self.tostring(pretty_print=True) + xml_fp.write(doc_str) + xml_fp.flush() + print "XML:" + print doc_str + xml_fp.seek(0) + + + def sign_message(self, priv_key_file, ca_cert_file, _node_ns=None, + _etree=None, _tempfile=None, _subprocess=None): + """Sign the XML-document and return the signed document + as a string.""" + if _etree is None: + _etree = etree + if _tempfile is None: + _tempfile = tempfile + if _subprocess is None: + _subprocess = subprocess + if _node_ns is None: + _node_ns = self.node_ns + + 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 + ',' + ca_cert_file, + '--id-attr:ID', + _node_ns, + xml_fp.name] + + print "COMMANDS", cmds + proc = subprocess.Popen( + cmds, + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + ) + + signed_message, err = proc.communicate() + if err: + raise SignableMessageError(err) + + return signed_message + diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index 262506c9..377c46f6 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -5,7 +5,11 @@ def main(*args): artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja', issuer='dille.ntnu.no') - print artifact_resolve.dump(pretty_print=True) + ## print artifact_resolve.tostring(pretty_print=True) + print artifact_resolve.sign_message( + '/var/www/idpp/pki/idppdev.it.ntnu.no.key', + '/var/www/idpp/pki/terena_ssl_ca_3.pem') + if __name__ == '__main__': main(sys.argv[1:]) From 1df45327ead3d2e30165e7abb467ff69d32b6f4a Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Wed, 4 Nov 2015 23:46:23 +0100 Subject: [PATCH 04/27] DATA-557. Added SOAPEnvelope-class. Cleaned up the code. --- idporten/saml/ArtifactResolve.py | 53 +++++++------ idporten/saml/SOAPEnvelope.py | 77 +++++++++++++++++++ ...SignableMessage.py => SignableDocument.py} | 48 +++++++++--- idporten/saml/mm.py | 6 +- 4 files changed, 147 insertions(+), 37 deletions(-) create mode 100644 idporten/saml/SOAPEnvelope.py rename idporten/saml/{SignableMessage.py => SignableDocument.py} (62%) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index 4338a3d2..dd71b318 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -1,18 +1,20 @@ -#import zlib -#import base64 +""" +Creates an SAML2 ArtifactResolve message. +""" +# +# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. +# import uuid -#import urllib -#import tempfile -#import subprocess as subp from datetime import datetime -from lxml import etree from lxml.builder import ElementMaker -from SignableMessage import SignableMessage +from SignableDocument import SignableDocument -class ArtifactResolve(SignableMessage): +class ArtifactResolve(SignableDocument): + """Creates an SAML2 ArtifactResolve message.""" + def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): """This should produce an SAML2 ArtifactResolve like this: @@ -49,11 +51,9 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): if _uuid is None: _uuid = uuid.uuid4 - issuer = kwargs.pop('issuer') - now = _clock() now = now.replace(microsecond=0) - now_iso = now.isoformat() + ".875Z" #TODO: add better format here + now_iso = now.isoformat() + ".875Z" unique_id = _uuid() unique_id = unique_id.hex @@ -72,8 +72,23 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): ID=unique_id, ) + artifact_resolve.append(self._create_signature(unique_id)) + + # Add Issuer and artifact under signature. + saml_issuer = saml_maker.Issuer() + saml_issuer.text = kwargs.get('issuer', '') + artifact_resolve.append(saml_issuer) + + saml_artifact = samlp_maker.Artifact() + saml_artifact.text = artifact + artifact_resolve.append(saml_artifact) + + self.document = artifact_resolve + - # Add XML-signature + @staticmethod + def _create_signature(unique_id): + """Craates 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#') @@ -81,7 +96,6 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): signature_elem = signature_maker.Signature() - artifact_resolve.append(signature_elem) signed_info_elem = signature_maker.SignedInfo() @@ -122,16 +136,5 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): key_info_elem.append(signature_maker.X509Data()) signature_elem.append(key_info_elem) - - # Add Issuer and artifact under signature. - saml_issuer = saml_maker.Issuer() - saml_issuer.text = issuer - artifact_resolve.append(saml_issuer) - - saml_artifact = samlp_maker.Artifact() - saml_artifact.text = artifact - artifact_resolve.append(saml_artifact) - - self.document = artifact_resolve - + return signature_elem diff --git a/idporten/saml/SOAPEnvelope.py b/idporten/saml/SOAPEnvelope.py new file mode 100644 index 00000000..340198be --- /dev/null +++ b/idporten/saml/SOAPEnvelope.py @@ -0,0 +1,77 @@ +""" +Creates a SOAP-Envelope. +""" +# +# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. +# +import re + +from lxml import etree +from lxml.builder import ElementMaker + + + +XML_DECL = re.compile('^<\?xml\ +version=..+$', re.IGNORECASE|re.MULTILINE) + + +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.""" + 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 = '\n%s' + soap_envelope.append(soap_body) + self.envelope = soap_envelope + + + def __str__(self): + """String-representation of this object.""" + return etree.tostring(self.envelope, xml_delaration=True, + pretty_print=True) + + + def __unicode__(self): + """Unicode-representation of this object.""" + return etree.tostring(self.envelope, xml_delaration=True, + encoding='UTF-8', pretty_print=True) + + + def tostring(self, body): + """Insert body as the SOAP-body and return this envelope as + a string. + + Raise SOAPEnvelopeError if the body-parameter is not a string + or unicode.""" + if isinstance(body, str) or isinstance(body, unicode): + # Get rid of the XML-declaration if it exists. + if XML_DECL.match(body): + splitted_body = body.split('\n') + body = '\n'.join(splitted_body[1:]) + return (etree.tostring(self.envelope, xml_declaration=True, + pretty_print=True, encoding='UTF-8') % body) + else: + raise SOAPEnvelopeError('Illegal parameter. Must be a ' + 'string or unicode') + diff --git a/idporten/saml/SignableMessage.py b/idporten/saml/SignableDocument.py similarity index 62% rename from idporten/saml/SignableMessage.py rename to idporten/saml/SignableDocument.py index 85104762..2ddf483e 100644 --- a/idporten/saml/SignableMessage.py +++ b/idporten/saml/SignableDocument.py @@ -1,27 +1,53 @@ +""" +A base class for signing XML-documents. Other classes that create +XML-documents should sub-class this class if they need to be signed. +""" +# +# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. +# import subprocess import platform import tempfile -import logging from lxml import etree -class SignableMessageError(Exception): +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 SignableMessage(object): +class SignableDocument(object): + """A base class for signing XML-documents""" + def __init__(self): - super(SignableMessage, self).__init__() + """ No parameters to __init__.""" + super(SignableDocument, self).__init__() self.document = None self.node_ns = None + def __str__(self): + """String-representation of this document.""" + return etree.tostring(self.document, xml_declaration=True, + pretty_print=True) + + + def __unicode__(self): + """Unicode-representation of this document.""" + return 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.""" @@ -31,7 +57,7 @@ def _get_xmlsec_bin(): return xmlsec_bin - def tostring(self, xml_declaration=True, encoding='UTF-8', + def tostring(self, xml_declaration=False, encoding='UTF-8', pretty_print=False): """Return the XML-document as a string.""" return etree.tostring(self.document, xml_declaration=xml_declaration, @@ -40,7 +66,7 @@ def tostring(self, xml_declaration=True, encoding='UTF-8', def write_xml_to_file(self, xml_fp): """Write the XML-document to a given file.""" - doc_str = self.tostring(pretty_print=True) + doc_str = self.tostring(xml_declaration=True, pretty_print=True) xml_fp.write(doc_str) xml_fp.flush() print "XML:" @@ -48,10 +74,12 @@ def write_xml_to_file(self, xml_fp): xml_fp.seek(0) - def sign_message(self, priv_key_file, ca_cert_file, _node_ns=None, + def sign_document(self, priv_key_file, ca_cert_file, _node_ns=None, _etree=None, _tempfile=None, _subprocess=None): """Sign the XML-document and return the signed document - as a string.""" + as a string. + + Raises SignableDocumentError if an error occurs.""" if _etree is None: _etree = etree if _tempfile is None: @@ -84,7 +112,7 @@ def sign_message(self, priv_key_file, ca_cert_file, _node_ns=None, signed_message, err = proc.communicate() if err: - raise SignableMessageError(err) + raise SignableDocumentError(err) return signed_message diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index 377c46f6..d936abc2 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -1,14 +1,16 @@ #! /usr/bin/env python import sys from ArtifactResolve import ArtifactResolve +from SOAPEnvelope import SOAPEnvelope def main(*args): artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja', issuer='dille.ntnu.no') - ## print artifact_resolve.tostring(pretty_print=True) - print artifact_resolve.sign_message( + signed_artifact = artifact_resolve.sign_document( '/var/www/idpp/pki/idppdev.it.ntnu.no.key', '/var/www/idpp/pki/terena_ssl_ca_3.pem') + soap_envelope = SOAPEnvelope() + print soap_envelope.tostring(signed_artifact) if __name__ == '__main__': From c330be4cbc6e67aa48637375d4352d2e37dde428 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 5 Nov 2015 00:09:54 +0100 Subject: [PATCH 05/27] DATA-557. Changed some default-settings. --- idporten/saml/SOAPEnvelope.py | 11 +++++++---- idporten/saml/SignableDocument.py | 4 ++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/idporten/saml/SOAPEnvelope.py b/idporten/saml/SOAPEnvelope.py index 340198be..7e78fea6 100644 --- a/idporten/saml/SOAPEnvelope.py +++ b/idporten/saml/SOAPEnvelope.py @@ -11,7 +11,8 @@ -XML_DECL = re.compile('^<\?xml\ +version=..+$', re.IGNORECASE|re.MULTILINE) +XML_DECL = re.compile('^<\?xml\ +version=.+encoding=.+\?>$', + re.IGNORECASE|re.MULTILINE) class SOAPEnvelopeError(Exception): @@ -58,7 +59,8 @@ def __unicode__(self): encoding='UTF-8', pretty_print=True) - def tostring(self, body): + def tostring(self, body, xml_declaration=True, encoding='UTF-8', + pretty_print=False): """Insert body as the SOAP-body and return this envelope as a string. @@ -69,8 +71,9 @@ def tostring(self, body): if XML_DECL.match(body): splitted_body = body.split('\n') body = '\n'.join(splitted_body[1:]) - return (etree.tostring(self.envelope, xml_declaration=True, - pretty_print=True, encoding='UTF-8') % body) + return (etree.tostring(self.envelope, + xml_declaration=xml_declaration, pretty_print=pretty_print, + encoding=encoding) % body) else: raise SOAPEnvelopeError('Illegal parameter. Must be a ' 'string or unicode') diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index 2ddf483e..398ea751 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -57,7 +57,7 @@ def _get_xmlsec_bin(): return xmlsec_bin - def tostring(self, xml_declaration=False, encoding='UTF-8', + def tostring(self, xml_declaration=True, encoding='UTF-8', pretty_print=False): """Return the XML-document as a string.""" return etree.tostring(self.document, xml_declaration=xml_declaration, @@ -66,7 +66,7 @@ def tostring(self, xml_declaration=False, encoding='UTF-8', def write_xml_to_file(self, xml_fp): """Write the XML-document to a given file.""" - doc_str = self.tostring(xml_declaration=True, pretty_print=True) + doc_str = self.tostring(pretty_print=True) xml_fp.write(doc_str) xml_fp.flush() print "XML:" From d5e0c2117afa0dd43661b58de5f89cb72ff39e14 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 5 Nov 2015 09:17:20 +0100 Subject: [PATCH 06/27] DATA-557. Cleaned up unnecessary code. --- idporten/saml/ArtifactResolve.py | 10 ++++---- idporten/saml/SOAPEnvelope.py | 39 ++++++------------------------- idporten/saml/SignableDocument.py | 7 +++--- idporten/saml/mm.py | 21 ++++++++++++++++- 4 files changed, 36 insertions(+), 41 deletions(-) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index dd71b318..51711f37 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -1,9 +1,10 @@ -""" -Creates an SAML2 ArtifactResolve message. -""" +# -*- coding: utf-8 -*- # # Copyright(c) 2015 Norwegian Univeristy of Science and Technology. # +""" +Creates an SAML2 ArtifactResolve message. +""" import uuid from datetime import datetime @@ -18,6 +19,7 @@ class ArtifactResolve(SignableDocument): def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): """This should produce an SAML2 ArtifactResolve like this: + $', - re.IGNORECASE|re.MULTILINE) - - class SOAPEnvelopeError(Exception): """There was a problem with the SOAP-body.""" @@ -42,39 +37,19 @@ def __init__(self): soap_envelope = soap_envelope_maker.Envelope() soap_body = soap_envelope_maker.Body() - soap_body.text = '\n%s' + 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_delaration=True, + 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_delaration=True, + return etree.tostring(self.envelope, xml_declaration=True, encoding='UTF-8', pretty_print=True) - - def tostring(self, body, xml_declaration=True, encoding='UTF-8', - pretty_print=False): - """Insert body as the SOAP-body and return this envelope as - a string. - - Raise SOAPEnvelopeError if the body-parameter is not a string - or unicode.""" - if isinstance(body, str) or isinstance(body, unicode): - # Get rid of the XML-declaration if it exists. - if XML_DECL.match(body): - splitted_body = body.split('\n') - body = '\n'.join(splitted_body[1:]) - return (etree.tostring(self.envelope, - xml_declaration=xml_declaration, pretty_print=pretty_print, - encoding=encoding) % body) - else: - raise SOAPEnvelopeError('Illegal parameter. Must be a ' - 'string or unicode') - diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index 398ea751..d841e7cd 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -1,10 +1,11 @@ +# -*- coding: utf-8 -*- +# +# Copyright(c) 2015 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. """ -# -# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. -# import subprocess import platform import tempfile diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index d936abc2..ca04ee28 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -1,16 +1,35 @@ #! /usr/bin/env python +# -*- coding: utf-8 -*- import sys +import re from ArtifactResolve import ArtifactResolve from SOAPEnvelope import SOAPEnvelope + +XML_DECL_RE = '^<\?xml\ +version=.+encoding=.+\?>' + +# Formatted XML match. +XML_DECL_NL = re.compile(XML_DECL_RE + '$',re.IGNORECASE|re.MULTILINE) +# Unformatted XML match. +XML_DECL_NONL = re.compile(XML_DECL_RE, re.IGNORECASE|re.MULTILINE) + + def main(*args): artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja', issuer='dille.ntnu.no') signed_artifact = artifact_resolve.sign_document( '/var/www/idpp/pki/idppdev.it.ntnu.no.key', '/var/www/idpp/pki/terena_ssl_ca_3.pem') + + # Get rid of the XML-declaration. + if XML_DECL_NL.match(signed_artifact): + signed_artifact = re.sub(XML_DECL_NL, '', signed_artifact) + if XML_DECL_NONL.match(signed_artifact): + signed_artifact = re.sub(XML_DECL_NONL, '', signed_artifact) + soap_envelope = SOAPEnvelope() - print soap_envelope.tostring(signed_artifact) + soap_message = (unicode(soap_envelope) % signed_artifact) + print soap_message if __name__ == '__main__': From 324e0f9cb174583619203d65da82ed92258612a5 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 5 Nov 2015 15:36:04 +0100 Subject: [PATCH 07/27] DATA-557. Added class for sending SOAP-requests and receiving SOAP-responses. Some clean-up and added flags to turn om debug. --- idporten/saml/ArtifactResolve.py | 15 ++++---- idporten/saml/HTTPSOpen.py | 61 +++++++++++++++++++++++++++++++ idporten/saml/SignableDocument.py | 15 +++++--- idporten/saml/mm.py | 5 +++ 4 files changed, 83 insertions(+), 13 deletions(-) create mode 100644 idporten/saml/HTTPSOpen.py diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index 51711f37..f93df655 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -16,20 +16,21 @@ class ArtifactResolve(SignableDocument): """Creates an SAML2 ArtifactResolve message.""" - def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): + def __init__(self, artifact, _clock=None, _uuid=None, _debug=False, + **kwargs): """This should produce an SAML2 ArtifactResolve like this: - %s + - + @@ -43,9 +44,9 @@ def __init__(self, artifact, _clock=None, _uuid=None, **kwargs): - %s + """ - super(ArtifactResolve, self).__init__() + super(ArtifactResolve, self).__init__(_debug) self.node_ns = 'urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve' if _clock is None: diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py new file mode 100644 index 00000000..dfc74a36 --- /dev/null +++ b/idporten/saml/HTTPSOpen.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +# +# Copright(c) 2015 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." + + def __init__(self, location_url, send_data, _method='POST', _timeout=30, + _debug=False): + """ """ + super(HTTPSOpen, self).__init__() + parsed_location = urlparse.urlparse(location_url) + + self.location_address = parsed_location.netloc + self.location_path = parsed_location.path + self.location_host = self.location_address.split(':')[0] + self.send_data = send_data + self.method = _method + self.timeout = _timeout + self.debug_conn = _debug + + + def communicate(self): + """Connect to the url, send request and get response.""" + if self.debug_conn: + print 'Connection parameters:' + print ('location_address = %s, location_path = %s, send_data = %s,' + ' location_host = %s, method = %s' % (self.location_address, + self.location_path, self.send_data, self.location_host, + self.method)) + + conn = httplib.HTTPSConnection(self.location_address, + timeout=self.timeout) + + conn.request(self.method, self.location_path, body=self.send_data, + headers={ + "Host": self.location_host, + "Content-Type": "text/xml; charset=UTF-8", + "Content-Length": len(self.send_data) + } + ) + conn_resp = None + http_response = conn.getresponse() + if http_response.status != httplib.OK: + print ('HTTPS-connectiom failed; status = %d, reason = %s' % + (http_response.status, http_response.reason)) + else: + conn_resp = http_response.read() + conn.close() + if self.debug_conn: + print 'Response:' + print conn_resp + return conn_resp diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index d841e7cd..e7636a85 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -30,11 +30,12 @@ def __str__(self): class SignableDocument(object): """A base class for signing XML-documents""" - def __init__(self): - """ No parameters to __init__.""" + def __init__(self, _debug=False): + """Turn on _debug to see what is going on in the background.""" super(SignableDocument, self).__init__() self.document = None self.node_ns = None + self.debug = _debug def __str__(self): @@ -70,8 +71,9 @@ def write_xml_to_file(self, xml_fp): doc_str = self.tostring(pretty_print=True) xml_fp.write(doc_str) xml_fp.flush() - print "XML:" - print doc_str + if self.debug: + print "XML:" + print doc_str xml_fp.seek(0) @@ -103,8 +105,9 @@ def sign_document(self, priv_key_file, ca_cert_file, _node_ns=None, '--id-attr:ID', _node_ns, xml_fp.name] - - print "COMMANDS", cmds + + if self.debug: + print "COMMANDS", cmds proc = subprocess.Popen( cmds, stderr=subprocess.PIPE, diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index ca04ee28..feb0d55a 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -4,6 +4,7 @@ import re from ArtifactResolve import ArtifactResolve from SOAPEnvelope import SOAPEnvelope +from HTTPSOpen import HTTPSOpen XML_DECL_RE = '^<\?xml\ +version=.+encoding=.+\?>' @@ -29,7 +30,11 @@ def main(*args): soap_envelope = SOAPEnvelope() soap_message = (unicode(soap_envelope) % signed_artifact) + print soap_message + # Usage: + # https_open = HTTPSOpen('', soap_message, _debug=True) + # resp = https_open.communicate() if __name__ == '__main__': From 304b46b3c4a197f86372bbae6103a25aa60d8d7d Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 5 Nov 2015 19:27:11 +0100 Subject: [PATCH 08/27] DATA-557. Added documentation, and a small extension. --- idporten/saml/HTTPSOpen.py | 5 +++-- idporten/saml/SOAPEnvelope.py | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index dfc74a36..ae9a54e1 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -14,7 +14,7 @@ class HTTPSOpen(object): "A class that communicates over HTTPS-connection." def __init__(self, location_url, send_data, _method='POST', _timeout=30, - _debug=False): + _content_type='text/xml; charset=UTF-8', _debug=False): """ """ super(HTTPSOpen, self).__init__() parsed_location = urlparse.urlparse(location_url) @@ -25,6 +25,7 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, self.send_data = send_data self.method = _method self.timeout = _timeout + self.content_type = _content_type self.debug_conn = _debug @@ -43,7 +44,7 @@ def communicate(self): conn.request(self.method, self.location_path, body=self.send_data, headers={ "Host": self.location_host, - "Content-Type": "text/xml; charset=UTF-8", + "Content-Type": self.content_type, "Content-Length": len(self.send_data) } ) diff --git a/idporten/saml/SOAPEnvelope.py b/idporten/saml/SOAPEnvelope.py index 601acb67..a7f78162 100644 --- a/idporten/saml/SOAPEnvelope.py +++ b/idporten/saml/SOAPEnvelope.py @@ -25,7 +25,16 @@ def __str__(self): class SOAPEnvelope(object): - """Creates a SOAP-envelope with a string-placeholder as body.""" + """Creates a SOAP-envelope with a string-placeholder as body, + like this: + + + + + %s + + + """ def __init__(self): """Creates a SOAP-envelope.""" super(SOAPEnvelope, self).__init__() From 9035f46c6040c1934bab8fd389a7a69e215e30f0 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Fri, 6 Nov 2015 11:04:09 +0100 Subject: [PATCH 09/27] DATA-557. Fixed syntax-error in case the script should take options and parameters. --- idporten/saml/mm.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index feb0d55a..ae4f7833 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -38,5 +38,5 @@ def main(*args): if __name__ == '__main__': - main(sys.argv[1:]) + main(*sys.argv[1:]) From 2333fb0bf0f3933790d5fb0e08435810973d8c23 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Tue, 17 Nov 2015 16:34:56 +0100 Subject: [PATCH 10/27] DATA-557. Possible to override the Elementree-object. Added lots for documentation. --- idporten/saml/ArtifactResolve.py | 19 +++++++--- idporten/saml/HTTPSOpen.py | 22 ++++++++++-- idporten/saml/SignableDocument.py | 59 ++++++++++++++++++++++--------- idporten/saml/mm.py | 3 +- 4 files changed, 79 insertions(+), 24 deletions(-) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index f93df655..ec7fdc62 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -16,9 +16,20 @@ class ArtifactResolve(SignableDocument): """Creates an SAML2 ArtifactResolve message.""" - def __init__(self, artifact, _clock=None, _uuid=None, _debug=False, - **kwargs): - """This should produce an SAML2 ArtifactResolve like this: + 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). + + + This class should produce an SAML2 ArtifactResolve protocol- + message like this: """ - super(ArtifactResolve, self).__init__(_debug) + super(ArtifactResolve, self).__init__(_etree, _debug) self.node_ns = 'urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve' if _clock is None: diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index ae9a54e1..72a150cb 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -11,11 +11,27 @@ class HTTPSOpen(object): - "A class that communicates over HTTPS-connection." + """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. + """ def __init__(self, location_url, send_data, _method='POST', _timeout=30, _content_type='text/xml; charset=UTF-8', _debug=False): - """ """ + """ + Post data to a given location (URL). + + 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) @@ -30,7 +46,7 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, def communicate(self): - """Connect to the url, send request and get response.""" + """Connect to the URL, send request and return the raw response.""" if self.debug_conn: print 'Connection parameters:' print ('location_address = %s, location_path = %s, send_data = %s,' diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index e7636a85..95f65120 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -28,25 +28,33 @@ def __str__(self): class SignableDocument(object): - """A base class for signing XML-documents""" + """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, _debug=False): - """Turn on _debug to see what is going on in the background.""" + def __init__(self, _etree=None, _debug=False): + """More or less an empty constructor. + + Keywords arguments: + _etree - Override the default etree-object (default None). + _debug -- Print debug-messages (default False). + """ super(SignableDocument, self).__init__() self.document = None self.node_ns = None self.debug = _debug + if _etree is None: + self._etree = etree def __str__(self): """String-representation of this document.""" - return etree.tostring(self.document, xml_declaration=True, + return self._etree.tostring(self.document, xml_declaration=True, pretty_print=True) def __unicode__(self): - """Unicode-representation of this document.""" - return etree.tostring(self.document, xml_declaration=True, + """Unicode-string of this document.""" + return self._etree.tostring(self.document, xml_declaration=True, encoding='UTF-8', pretty_print=True) @@ -61,13 +69,24 @@ def _get_xmlsec_bin(): def tostring(self, xml_declaration=True, encoding='UTF-8', pretty_print=False): - """Return the XML-document as a string.""" - return etree.tostring(self.document, xml_declaration=xml_declaration, - encoding=encoding, pretty_print=pretty_print) + """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.""" + """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() @@ -78,13 +97,18 @@ def write_xml_to_file(self, xml_fp): def sign_document(self, priv_key_file, ca_cert_file, _node_ns=None, - _etree=None, _tempfile=None, _subprocess=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_ns -- 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 _etree is None: - _etree = etree if _tempfile is None: _tempfile = tempfile if _subprocess is None: @@ -102,9 +126,12 @@ def sign_document(self, priv_key_file, ca_cert_file, _node_ns=None, '--sign', '--privkey-pem', priv_key_file + ',' + ca_cert_file, - '--id-attr:ID', - _node_ns, - xml_fp.name] + '--id-attr:ID'] + + if _node_ns: + cmds.append(_node_ns) + + cmds.append(xml_fp.name) if self.debug: print "COMMANDS", cmds diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index ae4f7833..f830cf40 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -33,8 +33,9 @@ def main(*args): print soap_message # Usage: - # https_open = HTTPSOpen('', soap_message, _debug=True) + # https_open = HTTPSOpen('https://www.ntnu.no', soap_message) # resp = https_open.communicate() + # print resp if __name__ == '__main__': From 40033a6d3751fc8ce56792760108df920b7538a6 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Fri, 15 Jan 2016 09:49:48 +0100 Subject: [PATCH 11/27] DATA-557. Getting ready for install. --- idporten/saml/__init__.py | 7 +++++++ setup.py | 10 ++++++---- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/idporten/saml/__init__.py b/idporten/saml/__init__.py index 0cc4ad8e..56ff433a 100644 --- a/idporten/saml/__init__.py +++ b/idporten/saml/__init__.py @@ -8,3 +8,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..5fa7eabb 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.2', 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', ) From 6a29a56f5e6835aa9fb3125cdfe30cc3149f427a Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 21 Jan 2016 07:48:42 +0100 Subject: [PATCH 12/27] DATA-557. Should be possible to choose assertion-binding. --- idporten/saml/AuthRequest.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/idporten/saml/AuthRequest.py b/idporten/saml/AuthRequest.py index af3a0164..914cb1cb 100644 --- a/idporten/saml/AuthRequest.py +++ b/idporten/saml/AuthRequest.py @@ -11,6 +11,7 @@ from SignableRequest import SignableRequest +LEGAL_BINDINGS = ['HTTP_POST', 'HTTP-Artifact'] class AuthRequest(SignableRequest): def __init__(self, @@ -25,6 +26,7 @@ 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 + assertion_binding -- The assertion-bind type, possible values HTTP_POST or HTTP-Artifact, default HTTP-Artifact. """ if _clock is None: _clock = datetime.utcnow @@ -35,7 +37,12 @@ def __init__(self, issuer = kwargs.pop('issuer') name_identifier_format = kwargs.pop('name_identifier_format') self.target_url = kwargs.pop('idp_sso_target_url') + assertion_binding = kwargs.pop('assertion_binding') + if assertion_binding is None or len(assertion_binding.strip()) == 0: + assertion_binding = 'HTTP-Artifact' + if not assert_binding in LEGAL_BINDINGS: + raise Exception('Illegal binding') now = _clock() # Resolution finer than milliseconds not allowed @@ -57,7 +64,7 @@ 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, From d6fc299b8bb967192ec05d396f50b1f1bab21b5c Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 21 Jan 2016 08:43:04 +0100 Subject: [PATCH 13/27] DATA-557. Added comment. --- idporten/saml/AuthRequest.py | 1 + 1 file changed, 1 insertion(+) diff --git a/idporten/saml/AuthRequest.py b/idporten/saml/AuthRequest.py index 914cb1cb..982bc278 100644 --- a/idporten/saml/AuthRequest.py +++ b/idporten/saml/AuthRequest.py @@ -37,6 +37,7 @@ def __init__(self, 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.pop('assertion_binding') if assertion_binding is None or len(assertion_binding.strip()) == 0: From 5914b809c9d1bce061336bc06d75e4cc5c732652 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 21 Jan 2016 12:13:43 +0100 Subject: [PATCH 14/27] DATA-557. Fixed a bug. --- idporten/saml/ArtifactResolve.py | 12 +++++++----- idporten/saml/AuthRequest.py | 8 ++++---- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index ec7fdc62..08a62090 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -26,7 +26,7 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=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: @@ -57,7 +57,7 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, """ - super(ArtifactResolve, self).__init__(_etree, _debug) + super(ArtifactResolve, self).__init__(_etree=_etree, _debug=_debug) self.node_ns = 'urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve' if _clock is None: @@ -71,6 +71,7 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, unique_id = _uuid() unique_id = unique_id.hex + issuer = kwargs.pop('issuer') samlp_maker = ElementMaker( namespace='urn:oasis:names:tc:SAML:2.0:protocol', @@ -86,13 +87,14 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, ID=unique_id, ) - artifact_resolve.append(self._create_signature(unique_id)) - # Add Issuer and artifact under signature. saml_issuer = saml_maker.Issuer() - saml_issuer.text = kwargs.get('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) diff --git a/idporten/saml/AuthRequest.py b/idporten/saml/AuthRequest.py index 982bc278..d267f46b 100644 --- a/idporten/saml/AuthRequest.py +++ b/idporten/saml/AuthRequest.py @@ -11,7 +11,7 @@ from SignableRequest import SignableRequest -LEGAL_BINDINGS = ['HTTP_POST', 'HTTP-Artifact'] +LEGAL_BINDINGS = ['HTTP-POST', 'HTTP-Artifact'] class AuthRequest(SignableRequest): def __init__(self, @@ -26,7 +26,7 @@ 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 - assertion_binding -- The assertion-bind type, possible values HTTP_POST or HTTP-Artifact, default HTTP-Artifact. + sp_assertion_binding -- The assertion-bind type, possible values HTTP_POST or HTTP-Artifact, default HTTP-Artifact. """ if _clock is None: _clock = datetime.utcnow @@ -38,11 +38,11 @@ def __init__(self, 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.pop('assertion_binding') + assertion_binding = kwargs.get('sp_assertion_binding', '') if assertion_binding is None or len(assertion_binding.strip()) == 0: assertion_binding = 'HTTP-Artifact' - if not assert_binding in LEGAL_BINDINGS: + if not assertion_binding in LEGAL_BINDINGS: raise Exception('Illegal binding') now = _clock() From 249ce7105ce6e6ae0f559d498c6a033a7e4226a4 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 21 Jan 2016 14:46:47 +0100 Subject: [PATCH 15/27] DATA-557. Trouble with communication,- but commit these changes for later. Not finished! --- idporten/saml/HTTPSOpen.py | 40 +++++++++++++++++++++++--------------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index 72a150cb..05cba9f1 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -8,6 +8,7 @@ import httplib import urlparse +import ssl class HTTPSOpen(object): @@ -17,7 +18,7 @@ class HTTPSOpen(object): """ def __init__(self, location_url, send_data, _method='POST', _timeout=30, - _content_type='text/xml; charset=UTF-8', _debug=False): + _content_type='text/soap+xml; charset=UTF-8', _debug=False): """ Post data to a given location (URL). @@ -35,9 +36,16 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, super(HTTPSOpen, self).__init__() parsed_location = urlparse.urlparse(location_url) - self.location_address = parsed_location.netloc + 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 - self.location_host = self.location_address.split(':')[0] self.send_data = send_data self.method = _method self.timeout = _timeout @@ -49,21 +57,21 @@ def communicate(self): """Connect to the URL, send request and return the raw response.""" if self.debug_conn: print 'Connection parameters:' - print ('location_address = %s, location_path = %s, send_data = %s,' - ' location_host = %s, method = %s' % (self.location_address, - self.location_path, self.send_data, self.location_host, - self.method)) + print ('location_host = %s, location_port = %d, ' + 'location_path = %s, method = %s, send_data = %s' % + (self.location_host, self.location_port, self.location_path, + self.method, self.send_data)) + + conn = httplib.HTTPSConnection(self.location_host, + port=self.location_port, + timeout=self.timeout) - conn = httplib.HTTPSConnection(self.location_address, - timeout=self.timeout) + headers = { + "Host": self.location_host, + "Content-Type": self.content_type, + } + conn.request(self.method, self.location_path, self.send_data, headers) - conn.request(self.method, self.location_path, body=self.send_data, - headers={ - "Host": self.location_host, - "Content-Type": self.content_type, - "Content-Length": len(self.send_data) - } - ) conn_resp = None http_response = conn.getresponse() if http_response.status != httplib.OK: From 69219dc5369d26b682af8ffd30749beb83bc3238 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Fri, 22 Jan 2016 15:29:58 +0100 Subject: [PATCH 16/27] DATA-557. Added some debug. --- idporten/saml/HTTPSOpen.py | 10 ++++++---- idporten/saml/mm.py | 6 +++--- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index 05cba9f1..141484f3 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -46,10 +46,10 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, self.location_host = host_and_port[0] self.location_port = httplib.HTTPS_PORT self.location_path = parsed_location.path - self.send_data = send_data self.method = _method - self.timeout = _timeout self.content_type = _content_type + self.send_data = send_data + self.timeout = _timeout self.debug_conn = _debug @@ -58,9 +58,10 @@ def communicate(self): if self.debug_conn: print 'Connection parameters:' print ('location_host = %s, location_port = %d, ' - 'location_path = %s, method = %s, send_data = %s' % + '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.send_data)) + self.method, self.content_type, self.timeout, self.send_data)) conn = httplib.HTTPSConnection(self.location_host, port=self.location_port, @@ -69,6 +70,7 @@ def communicate(self): headers = { "Host": self.location_host, "Content-Type": self.content_type, + "Content-Length": len(self.send_data), } conn.request(self.method, self.location_path, self.send_data, headers) diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index f830cf40..686df8e8 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -33,9 +33,9 @@ def main(*args): print soap_message # Usage: - # https_open = HTTPSOpen('https://www.ntnu.no', soap_message) - # resp = https_open.communicate() - # print resp + https_open = HTTPSOpen('https://www.ntnu.no', soap_message) + resp = https_open.communicate() + print resp if __name__ == '__main__': From f27c2493dd836d663b87dd62f3fa23b6d691fc6d Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 28 Jan 2016 10:06:53 +0100 Subject: [PATCH 17/27] DATA-557. Added modeline for vim. --- idporten/saml/ArtifactResolve.py | 1 + idporten/saml/HTTPSOpen.py | 5 +++++ idporten/saml/SOAPEnvelope.py | 1 + idporten/saml/SignableDocument.py | 1 + idporten/saml/mm.py | 2 +- 5 files changed, 9 insertions(+), 1 deletion(-) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index 08a62090..08b4d9b0 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# vim:sw=4:ts=4:et: # # Copyright(c) 2015 Norwegian Univeristy of Science and Technology. # diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index 141484f3..cfcf9fe2 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# vim:sw=4:ts=4:et: # # Copright(c) 2015 Norwegian University of Science and Technology # @@ -46,6 +47,8 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, 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 @@ -72,6 +75,8 @@ def communicate(self): "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, self.send_data, headers) conn_resp = None diff --git a/idporten/saml/SOAPEnvelope.py b/idporten/saml/SOAPEnvelope.py index a7f78162..bf251457 100644 --- a/idporten/saml/SOAPEnvelope.py +++ b/idporten/saml/SOAPEnvelope.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# vim:sw=4:ts=4:et: # # Copyright(c) 2015 Norwegian Univeristy of Science and Technology. # diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index 95f65120..e9d5c31f 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +# vim:sw=4:ts=4:et: # # Copyright(c) 2015 Norwegian Univeristy of Science and Technology. # diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index 686df8e8..31250a00 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -33,7 +33,7 @@ def main(*args): print soap_message # Usage: - https_open = HTTPSOpen('https://www.ntnu.no', soap_message) + https_open = HTTPSOpen('https://www.ntnu.no', soap_message, _debug=True) resp = https_open.communicate() print resp From a5c7bfe569e319941ee37a2f8695d2249a138b67 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 28 Jan 2016 10:07:33 +0100 Subject: [PATCH 18/27] DATA-557. Added modeline for vim. --- idporten/saml/mm.py | 1 + 1 file changed, 1 insertion(+) diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index 31250a00..f7e95733 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -1,5 +1,6 @@ #! /usr/bin/env python # -*- coding: utf-8 -*- +# vim:sw=4:ts=4:et: import sys import re from ArtifactResolve import ArtifactResolve From c5a5503aa4a031e752e05622dc1eef5127c06bd7 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Mon, 1 Feb 2016 11:54:33 +0100 Subject: [PATCH 19/27] DATA-557. Changed documentation. Added names of positional parameters just in case another python-version is used. --- idporten/saml/HTTPSOpen.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index cfcf9fe2..3b2fad9e 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -19,9 +19,11 @@ class HTTPSOpen(object): """ def __init__(self, location_url, send_data, _method='POST', _timeout=30, - _content_type='text/soap+xml; charset=UTF-8', _debug=False): + _content_type='text/xml; charset=UTF-8', _debug=False): """ - Post data to a given location (URL). + 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. @@ -57,7 +59,9 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, def communicate(self): - """Connect to the URL, send request and return the raw response.""" + """ + Connect to the URL, send request and return the raw response. + """ if self.debug_conn: print 'Connection parameters:' print ('location_host = %s, location_port = %d, ' @@ -67,8 +71,8 @@ def communicate(self): self.method, self.content_type, self.timeout, self.send_data)) conn = httplib.HTTPSConnection(self.location_host, - port=self.location_port, - timeout=self.timeout) + port=self.location_port, + timeout=self.timeout) headers = { "Host": self.location_host, @@ -77,7 +81,8 @@ def communicate(self): } if self.debug_conn: print ('Headers:\n%s' % str(headers)) - conn.request(self.method, self.location_path, self.send_data, headers) + conn.request(self.method, self.location_path, body=self.send_data, + headers=headers) conn_resp = None http_response = conn.getresponse() From 18ea286c9669c686bc97aca6c47d9baf60f62da7 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Wed, 10 Feb 2016 12:15:48 +0100 Subject: [PATCH 20/27] DATA-557. A working version. Need more clean-up in the code. --- idporten/saml/ArtifactResolve.py | 5 +- idporten/saml/ArtifactResponse.py | 137 +++++++++++++++++++++++++++++ idporten/saml/SignableDocument.py | 11 +-- idporten/saml/SignatureVerifier.py | 18 ++-- 4 files changed, 157 insertions(+), 14 deletions(-) create mode 100644 idporten/saml/ArtifactResponse.py diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index 08b4d9b0..d69a2abc 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -58,8 +58,9 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, """ - super(ArtifactResolve, self).__init__(_etree=_etree, _debug=_debug) - self.node_ns = 'urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve' + super(ArtifactResolve, self).__init__( + _node_ns='urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve', + _etree=_etree, _debug=_debug) if _clock is None: _clock = datetime.utcnow diff --git a/idporten/saml/ArtifactResponse.py b/idporten/saml/ArtifactResponse.py new file mode 100644 index 00000000..c619b603 --- /dev/null +++ b/idporten/saml/ArtifactResponse.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +""" +Creates an SAML2 ArtifactResponse message. +""" +import base64 + +from lxml import etree +from datetime import datetime, timedelta + +from SignatureVerifier import SignatureVerifier + +from Response import ResponseValidationError, ResponseNameIDError +from Response import ResponseConditionError + + +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.""" + + 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._document = _etree.fromstring(art_resp) + + + def _parse_datetime(self, dt): + return datetime.strptime(dt, '%Y-%m-%dT%H:%M:%SZ') + + + def is_valid(self, idp_cert_filename, private_key_file, + _clock=None, _verifier=None + ): + """ + Verify that the samlp:Response 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_ns='urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResponse') + + self.decrypted = decrypted + self._decrypted_document = etree.fromstring(self.decrypted) + return is_valid + + + 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 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] + + + def get_session_index(self): + 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_name_id(self): + 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", + ) + diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index e9d5c31f..6afe3e6a 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -32,16 +32,17 @@ 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, _etree=None, _debug=False): + def __init__(self, _node_ns=None, _etree=None, _debug=False): """More or less an empty constructor. Keywords arguments: - _etree - Override the default etree-object (default None). + _node_ns -- Namespace for the element to be signed (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_ns = None + self.node_ns = _node_ns self.debug = _debug if _etree is None: self._etree = etree @@ -97,7 +98,7 @@ def write_xml_to_file(self, xml_fp): xml_fp.seek(0) - def sign_document(self, priv_key_file, ca_cert_file, _node_ns=None, + def sign_document(self, priv_key_file, _node_ns=None, _tempfile=None, _subprocess=None): """Sign the XML-document and return the signed document as a string. @@ -126,7 +127,7 @@ def sign_document(self, priv_key_file, ca_cert_file, _node_ns=None, cmds = [xmlsec_bin, '--sign', '--privkey-pem', - priv_key_file + ',' + ca_cert_file, + priv_key_file, '--id-attr:ID'] if _node_ns: diff --git a/idporten/saml/SignatureVerifier.py b/idporten/saml/SignatureVerifier.py index 6eea6934..ef1fad2d 100644 --- a/idporten/saml/SignatureVerifier.py +++ b/idporten/saml/SignatureVerifier.py @@ -23,11 +23,12 @@ 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_ns=None): return self.verify(document, signature, self.idp_cert_filename, - self.private_key_file) + self.private_key_file, + _node_ns=_node_ns) @staticmethod def _get_xmlsec_bin(): @@ -44,6 +45,7 @@ def verify( signature, idp_cert_filename, private_key_file, + _node_ns=None, _etree=None, _tempfile=None, _subprocess=None, @@ -72,7 +74,7 @@ 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_ns=_node_ns) if verified: decrypted = self.decrypt_xml(xml_fp.name, xmlsec_bin, private_key_file) @@ -104,7 +106,7 @@ def _parse_stderr(proc): ) @staticmethod - def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename): + def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename, _node_ns=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,10 +116,12 @@ def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename): '--verify', '--pubkey-cert-pem', idp_cert_filename, - '--id-attr', - 'ID', - xml_filename, + '--id-attr:ID', ] + if _node_ns: + cmds.append(_node_ns) + + cmds.append(xml_filename) print "COMMANDS", cmds proc = subprocess.Popen( From 8a979e735087b01edeb35d4057241a115de35c8c Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Thu, 11 Feb 2016 14:07:24 +0100 Subject: [PATCH 21/27] DATA-557. Ready for review. --- idporten/saml/ArtifactResolve.py | 9 +- idporten/saml/ArtifactResponse.py | 147 +++++++++++++++++++---------- idporten/saml/AuthRequest.py | 23 ++--- idporten/saml/HTTPSOpen.py | 6 +- idporten/saml/LogoutRequest.py | 9 +- idporten/saml/LogoutResponse.py | 14 ++- idporten/saml/Response.py | 49 ++++++---- idporten/saml/SOAPEnvelope.py | 2 +- idporten/saml/SignableDocument.py | 22 ++--- idporten/saml/SignableRequest.py | 13 ++- idporten/saml/SignatureVerifier.py | 42 ++++++--- idporten/saml/__init__.py | 2 + idporten/saml/mm.py | 5 +- 13 files changed, 214 insertions(+), 129 deletions(-) diff --git a/idporten/saml/ArtifactResolve.py b/idporten/saml/ArtifactResolve.py index d69a2abc..ffaa42e2 100644 --- a/idporten/saml/ArtifactResolve.py +++ b/idporten/saml/ArtifactResolve.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# vim:sw=4:ts=4:et: +# vim: et:ts=4:sw=4:sts=4 # -# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. # """ Creates an SAML2 ArtifactResolve message. @@ -59,7 +59,7 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, """ super(ArtifactResolve, self).__init__( - _node_ns='urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve', + _node_name='urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResolve', _etree=_etree, _debug=_debug) if _clock is None: @@ -89,7 +89,6 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, ID=unique_id, ) - saml_issuer = saml_maker.Issuer() saml_issuer.text = issuer artifact_resolve.append(saml_issuer) @@ -106,7 +105,7 @@ def __init__(self, artifact, _etree=None, _clock=None, _uuid=None, @staticmethod def _create_signature(unique_id): - """Craates all XML-elements needed for an XML-signature.""" + """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#') diff --git a/idporten/saml/ArtifactResponse.py b/idporten/saml/ArtifactResponse.py index c619b603..426c582f 100644 --- a/idporten/saml/ArtifactResponse.py +++ b/idporten/saml/ArtifactResponse.py @@ -1,6 +1,11 @@ # -*- 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 @@ -10,15 +15,18 @@ from SignatureVerifier import SignatureVerifier from Response import ResponseValidationError, ResponseNameIDError -from Response import ResponseConditionError 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.""" + """ + 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__() @@ -28,18 +36,83 @@ def __init__(self, art_resp, _base64=None, _etree=None): _etree = etree self._signature = None + self.decrypted = None + self._decrypted_document = None self._document = _etree.fromstring(art_resp) - def _parse_datetime(self, dt): - return datetime.strptime(dt, '%Y-%m-%dT%H:%M:%SZ') + 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:Response is valid. + Verify that the samlp:ArtifactResponse is valid. Return True if valid, otherwise False. """ if _clock is None: @@ -48,7 +121,8 @@ def is_valid(self, idp_cert_filename, private_key_file, _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', + ('/soap-env:Envelope/soap-env:Body/samlp:ArtifactResponse' + '/samlp:Response/saml:Assertion/saml:Conditions'), namespaces=namespaces, ) @@ -61,8 +135,9 @@ def is_valid(self, idp_cert_filename, private_key_file, 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') + #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') @@ -79,59 +154,27 @@ def is_valid(self, idp_cert_filename, private_key_file, # 'Current time is on or after NotOnOrAfter condition' # ) - is_valid, decrypted = _verifier.verify_and_decrypt(self._document, self._signature, _node_ns='urn:oasis:names:tc:SAML:2.0:protocol:ArtifactResponse') + 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_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 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('/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] - - - def get_session_index(self): 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_name_id(self): - 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() + ('/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] - name_id = property( - fget=_get_name_id, - doc="The value requested in the name_identifier_format, e.g., the user's email address", - ) diff --git a/idporten/saml/AuthRequest.py b/idporten/saml/AuthRequest.py index d267f46b..c804dcfd 100644 --- a/idporten/saml/AuthRequest.py +++ b/idporten/saml/AuthRequest.py @@ -1,12 +1,8 @@ -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 @@ -28,12 +24,15 @@ def __init__(self, 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') @@ -65,7 +64,8 @@ def __init__(self, ) authn_request = samlp_maker.AuthnRequest( - ProtocolBinding=('urn:oasis:names:tc:SAML:2.0:bindings:%s' % assertion_binding), + ProtocolBinding = ('urn:oasis:names:tc:SAML:2.0:bindings:%s' % + assertion_binding), Version='2.0', IssueInstant=now_iso, ID=unique_id, @@ -90,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 index 3b2fad9e..68171ba2 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# vim:sw=4:ts=4:et: +# vim: et:ts=4:sw=4:sts=4 # -# Copright(c) 2015 Norwegian University of Science and Technology +# Copright(c) 2016 Norwegian University of Science and Technology # """ A class that communicates over HTTPS-connection. @@ -9,7 +9,6 @@ import httplib import urlparse -import ssl class HTTPSOpen(object): @@ -96,3 +95,4 @@ def communicate(self): 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 index bf251457..78111d48 100644 --- a/idporten/saml/SOAPEnvelope.py +++ b/idporten/saml/SOAPEnvelope.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # vim:sw=4:ts=4:et: # -# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. # """ Creates a SOAP-Envelope. diff --git a/idporten/saml/SignableDocument.py b/idporten/saml/SignableDocument.py index 6afe3e6a..fe11c9c7 100644 --- a/idporten/saml/SignableDocument.py +++ b/idporten/saml/SignableDocument.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- -# vim:sw=4:ts=4:et: +# vim: et:ts=4:sw=4:sts=4 # -# Copyright(c) 2015 Norwegian Univeristy of Science and Technology. +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. # """ A base class for signing XML-documents. Other classes that create @@ -32,17 +32,17 @@ 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_ns=None, _etree=None, _debug=False): + def __init__(self, _node_name=None, _etree=None, _debug=False): """More or less an empty constructor. Keywords arguments: - _node_ns -- Namespace for the element to be signed (default None). + _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_ns = _node_ns + self.node_name = _node_name self.debug = _debug if _etree is None: self._etree = etree @@ -98,7 +98,7 @@ def write_xml_to_file(self, xml_fp): xml_fp.seek(0) - def sign_document(self, priv_key_file, _node_ns=None, + 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. @@ -106,7 +106,7 @@ def sign_document(self, priv_key_file, _node_ns=None, Keyword arguments: priv_key_file -- File containing the private key to use for signing. ca_cert_file -- File containing the CA-certificate. - _node_ns -- The XML-node where the signing should start. + _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). @@ -115,8 +115,8 @@ def sign_document(self, priv_key_file, _node_ns=None, _tempfile = tempfile if _subprocess is None: _subprocess = subprocess - if _node_ns is None: - _node_ns = self.node_ns + if _node_name is None: + _node_name = self.node_name signed_message = None with _tempfile.NamedTemporaryFile(suffix='.xml', @@ -130,8 +130,8 @@ def sign_document(self, priv_key_file, _node_ns=None, priv_key_file, '--id-attr:ID'] - if _node_ns: - cmds.append(_node_ns) + if _node_name: + cmds.append(_node_name) cmds.append(xml_fp.name) 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 ef1fad2d..84045799 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,12 +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, _node_ns=None): + def verify_and_decrypt(self, document, signature, _node_name=None): return self.verify(document, signature, self.idp_cert_filename, self.private_key_file, - _node_ns=_node_ns) + _node_name=_node_name) + @staticmethod def _get_xmlsec_bin(): @@ -45,7 +49,7 @@ def verify( signature, idp_cert_filename, private_key_file, - _node_ns=None, + _node_name=None, _etree=None, _tempfile=None, _subprocess=None, @@ -74,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, _node_ns=_node_ns) + 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() @@ -102,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, _node_ns=None): + 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. @@ -118,12 +127,12 @@ def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename, _node_ns=None): idp_cert_filename, '--id-attr:ID', ] - if _node_ns: - cmds.append(_node_ns) + if _node_name: + cmds.append(_node_name) cmds.append(xml_filename) - print "COMMANDS", cmds + # print "COMMANDS", cmds proc = subprocess.Popen( cmds, stderr=subprocess.PIPE, @@ -143,7 +152,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, @@ -152,12 +161,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 56ff433a..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, diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py index f7e95733..d8a03ba0 100755 --- a/idporten/saml/mm.py +++ b/idporten/saml/mm.py @@ -1,6 +1,9 @@ #! /usr/bin/env python # -*- coding: utf-8 -*- -# vim:sw=4:ts=4:et: +# vim: et:ts=4:sw=4:sts=4 +# +# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. +# import sys import re from ArtifactResolve import ArtifactResolve From 291d2f30d4857d021a803a547c5bfe9cc4802386 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Fri, 12 Feb 2016 09:16:33 +0100 Subject: [PATCH 22/27] DATA-557. Some more checking of the HTTP-status. --- idporten/saml/HTTPSOpen.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index 68171ba2..9ec8fbfc 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -15,6 +15,8 @@ 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, @@ -60,6 +62,8 @@ def __init__(self, location_url, send_data, _method='POST', _timeout=30, 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:' @@ -85,10 +89,13 @@ def communicate(self): conn_resp = None http_response = conn.getresponse() - if http_response.status != httplib.OK: - print ('HTTPS-connectiom failed; status = %d, reason = %s' % + if http_response.status >= httplib.MULTIPLE_CHOICES: + print ('HTTPS-connection failed; status = %d, reason = %s' % (http_response.status, http_response.reason)) - else: + 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: From bf2c428ea7c380c8a42c9138a9453cca2760b00f Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Fri, 12 Feb 2016 12:06:40 +0100 Subject: [PATCH 23/27] DATA-557. Updated documentation. --- example.cfg | 12 ++++++++++++ 1 file changed, 12 insertions(+) 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 = From 36a27415eeefaf2486618436b9fc5485b49725c0 Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Tue, 16 Feb 2016 09:12:30 +0100 Subject: [PATCH 24/27] DATA-557. Deleted test-file. --- idporten/saml/mm.py | 47 --------------------------------------------- 1 file changed, 47 deletions(-) delete mode 100755 idporten/saml/mm.py diff --git a/idporten/saml/mm.py b/idporten/saml/mm.py deleted file mode 100755 index d8a03ba0..00000000 --- a/idporten/saml/mm.py +++ /dev/null @@ -1,47 +0,0 @@ -#! /usr/bin/env python -# -*- coding: utf-8 -*- -# vim: et:ts=4:sw=4:sts=4 -# -# Copyright(c) 2016 Norwegian Univeristy of Science and Technology. -# -import sys -import re -from ArtifactResolve import ArtifactResolve -from SOAPEnvelope import SOAPEnvelope -from HTTPSOpen import HTTPSOpen - - -XML_DECL_RE = '^<\?xml\ +version=.+encoding=.+\?>' - -# Formatted XML match. -XML_DECL_NL = re.compile(XML_DECL_RE + '$',re.IGNORECASE|re.MULTILINE) -# Unformatted XML match. -XML_DECL_NONL = re.compile(XML_DECL_RE, re.IGNORECASE|re.MULTILINE) - - -def main(*args): - artifact_resolve = ArtifactResolve('adalsjljdaljdaljsdsja', - issuer='dille.ntnu.no') - signed_artifact = artifact_resolve.sign_document( - '/var/www/idpp/pki/idppdev.it.ntnu.no.key', - '/var/www/idpp/pki/terena_ssl_ca_3.pem') - - # Get rid of the XML-declaration. - if XML_DECL_NL.match(signed_artifact): - signed_artifact = re.sub(XML_DECL_NL, '', signed_artifact) - if XML_DECL_NONL.match(signed_artifact): - signed_artifact = re.sub(XML_DECL_NONL, '', signed_artifact) - - soap_envelope = SOAPEnvelope() - soap_message = (unicode(soap_envelope) % signed_artifact) - - print soap_message - # Usage: - https_open = HTTPSOpen('https://www.ntnu.no', soap_message, _debug=True) - resp = https_open.communicate() - print resp - - -if __name__ == '__main__': - main(*sys.argv[1:]) - From b913d8d51a9c8def3ae84770272a2a529355201d Mon Sep 17 00:00:00 2001 From: Trond Kandal Date: Tue, 16 Feb 2016 09:14:20 +0100 Subject: [PATCH 25/27] DATA-557. Typo. --- idporten/saml/HTTPSOpen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/idporten/saml/HTTPSOpen.py b/idporten/saml/HTTPSOpen.py index 9ec8fbfc..aaf14dc9 100644 --- a/idporten/saml/HTTPSOpen.py +++ b/idporten/saml/HTTPSOpen.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # vim: et:ts=4:sw=4:sts=4 # -# Copright(c) 2016 Norwegian University of Science and Technology +# Copyright(c) 2016 Norwegian University of Science and Technology # """ A class that communicates over HTTPS-connection. From c1fab20d2ea5a71a4437e0e2fe7df85e68540a00 Mon Sep 17 00:00:00 2001 From: Geir Hauge Date: Tue, 29 Mar 2016 15:28:03 +0200 Subject: [PATCH 26/27] Bumpe versjon --- setup.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 5fa7eabb..cfec5f49 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def run(self): setup( name='idporten.saml', - version='0.0.2', + version='0.0.3', description="Python client library for ID-porten SAML Version 2.0", packages = find_packages(), namespace_packages = ['idporten'], From 9a1f4388dc8f4b3d4b70dea849465c4b48a43fda Mon Sep 17 00:00:00 2001 From: Steinar Kleven Date: Fri, 15 Apr 2016 15:12:04 +0200 Subject: [PATCH 27/27] parameter error to xmlsec1 --- idporten/saml/SignatureVerifier.py | 3 +-- setup.py | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/idporten/saml/SignatureVerifier.py b/idporten/saml/SignatureVerifier.py index 84045799..3be85c34 100644 --- a/idporten/saml/SignatureVerifier.py +++ b/idporten/saml/SignatureVerifier.py @@ -125,10 +125,9 @@ def verify_xml(xml_filename, xmlsec_bin, idp_cert_filename, '--verify', '--pubkey-cert-pem', idp_cert_filename, - '--id-attr:ID', ] if _node_name: - cmds.append(_node_name) + cmds.extend(['--id-attr', _node_name]) cmds.append(xml_filename) diff --git a/setup.py b/setup.py index cfec5f49..9660dd86 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ def run(self): setup( name='idporten.saml', - version='0.0.3', + version='0.0.4', description="Python client library for ID-porten SAML Version 2.0", packages = find_packages(), namespace_packages = ['idporten'],