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