diff options
author | Ivan Kanakarakis <ivan.kanak@gmail.com> | 2019-12-26 20:52:23 +0200 |
---|---|---|
committer | Ivan Kanakarakis <ivan.kanak@gmail.com> | 2019-12-26 20:53:35 +0200 |
commit | 324656e6321fdf5a80184951d2ecaea644a737d8 (patch) | |
tree | 0f4bba69c9f77f4987ca5f6a0f20154e43d2eb1e | |
parent | b3635ec9792e0d2bc9682a14987248f37242a659 (diff) | |
parent | 9030d036e4b0473ff57763e638e4afcbf7b3f481 (diff) | |
download | pysaml2-324656e6321fdf5a80184951d2ecaea644a737d8.tar.gz |
Merge branch 'feature-add-metadata-freshness'
Define a period for which the metadata fetched from an MDQ are considered valid.
Signed-off-by: Ivan Kanakarakis <ivan.kanak@gmail.com>
-rw-r--r-- | docs/howto/config.rst | 230 | ||||
-rw-r--r-- | src/saml2/mdstore.py | 109 | ||||
-rw-r--r-- | src/saml2/time_util.py | 15 | ||||
-rw-r--r-- | tests/test_10_time_util.py | 6 | ||||
-rw-r--r-- | tests/test_30_mdstore.py | 59 |
5 files changed, 282 insertions, 137 deletions
diff --git a/docs/howto/config.rst b/docs/howto/config.rst index 0e3be8a8..4ce09873 100644 --- a/docs/howto/config.rst +++ b/docs/howto/config.rst @@ -16,29 +16,37 @@ The basic structure of the configuration file is therefore like this:: from saml2 import BINDING_HTTP_REDIRECT CONFIG = { - "entityid" : "http://saml.example.com:saml/idp.xml", - "name" : "Rolands IdP", + "entityid": "http://saml.example.com:saml/idp.xml", + "name": "Rolands IdP", "service": { "idp": { - "endpoints" : { - "single_sign_on_service" : [ - ("http://saml.example.com:saml:8088/sso", - BINDING_HTTP_REDIRECT)], + "endpoints": { + "single_sign_on_service": [ + ( + "http://saml.example.com:saml:8088/sso", + BINDING_HTTP_REDIRECT, + ), + ], "single_logout_service": [ - ("http://saml.example.com:saml:8088/slo", - BINDING_HTTP_REDIRECT)] + ( + "http://saml.example.com:saml:8088/slo", + BINDING_HTTP_REDIRECT, + ), + ], }, ... } }, - "key_file" : "my.key", - "cert_file" : "ca.pem", - "xmlsec_binary" : "/usr/local/bin/xmlsec1", + "key_file": "my.key", + "cert_file": "ca.pem", + "xmlsec_binary": "/usr/local/bin/xmlsec1", "delete_tmpfiles": True, "metadata": { - "local": ["edugain.xml"], + "local": [ + "edugain.xml", + ], }, - "attribute_map_dir" : "attributemaps", + "attribute_map_dir": "attributemaps", ... } @@ -93,7 +101,7 @@ A typical map file will look like this:: 'urn:mace:dir:attribute-def:associatedDomain': 'associatedDomain', 'urn:mace:dir:attribute-def:associatedName': 'associatedName', ... - }, + }, "to": { 'aRecord': 'urn:mace:dir:attribute-def:aRecord', 'aliasedEntryName': 'urn:mace:dir:attribute-def:aliasedEntryName', @@ -135,19 +143,22 @@ about the service or if support is needed. The possible types are according to the standard **technical**, **support**, **administrative**, **billing** and **other**.:: - contact_person: [{ - "givenname": "Derek", - "surname": "Jeter", - "company": "Example Co.", - "mail": ["jeter@example.com"], - "type": "technical", - },{ - "givenname": "Joe", - "surname": "Girardi", - "company": "Example Co.", - "mail": "girardi@example.com", - "type": "administrative", - }] + contact_person: [ + { + "givenname": "Derek", + "surname": "Jeter", + "company": "Example Co.", + "mail": ["jeter@example.com"], + "type": "technical", + }, + { + "givenname": "Joe", + "surname": "Girardi", + "company": "Example Co.", + "mail": "girardi@example.com", + "type": "administrative", + }, + ] debug ^^^^^ @@ -193,7 +204,7 @@ Contains a list of places where metadata can be found. This can be For example:: - "metadata" : { + "metadata": { "local": [ "/opt/metadata" "metadata.xml", @@ -209,6 +220,7 @@ For example:: { "url": "http://mdq.ukfederation.org.uk/", "cert": "ukfederation-mdq.pem", + "freshness_period": "P0Y0M0DT2H0M0S", }, ], }, @@ -221,6 +233,17 @@ metadata signing certificates should be used. These public keys must be acquired by some secure out-of-band method before being placed on the local file system. +When using MDQ, the `freshness_period` option can be set to define a period for +which the metadata fetched from the the MDQ server are considered fresh. After +that period has passed the metadata are not valid anymore and must be fetched +again. The period must be in the format defined in +`ISO 8601 <https://www.iso.org/iso-8601-date-and-time-format.html>`_ +or `RFC3999 <https://tools.ietf.org/html/rfc3339#appendix-A>`_. + +By default, if `freshness_period` is not defined, the metadata are refreshed +every 12 hours (`P0Y0M0DT12H0M0S`). + + organization ^^^^^^^^^^^^ @@ -228,9 +251,15 @@ Only used by *make_metadata.py*. Where you describe the organization responsible for the service.:: "organization": { - "name": [("Example Company","en"), ("Exempel AB","se")], + "name": [ + ("Example Company", "en"), + ("Exempel AB", "se") + ], "display_name": ["Exempel AB"], - "url": [("http://example.com","en"),("http://exempel.se","se")], + "url": [ + ("http://example.com", "en"), + ("http://exempel.se", "se"), + ], } .. note:: You can specify the language of the name, or the language used on @@ -280,14 +309,22 @@ So if a server is a Service Provider (SP) then the configuration could look something like this:: "service": { - "sp":{ - "name" : "Rolands SP", - "endpoints":{ + "sp": { + "name": "Rolands SP", + "endpoints": { "assertion_consumer_service": ["http://localhost:8087/"], - "single_logout_service" : [("http://localhost:8087/slo", - 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect')], + "single_logout_service": [ + ( + "http://localhost:8087/slo", + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect', + ), + ], }, - "required_attributes": ["surname", "givenname", "edupersonaffiliation"], + "required_attributes": [ + "surname", + "givenname", + "edupersonaffiliation", + ], "optional_attributes": ["title"], "idp": { "urn:mace:umu.se:saml:roland:idp": None, @@ -384,7 +421,7 @@ An example might be:: }, "urn:mace:example.com:saml:roland:sp": { "lifetime": {"minutes": 5}, - "attribute_restrictions":{ + "attribute_restrictions": { "givenName": None, "surName": None, } @@ -420,7 +457,7 @@ regular expressions.:: "policy": { "urn:mace:umu.se:saml:roland:sp": { "lifetime": {"minutes": 5}, - "attribute_restrictions":{ + "attribute_restrictions": { "mail": [".*\.umu\.se$"], } } @@ -661,7 +698,11 @@ Example:: "service": { "sp": { - "required_attributes": ["surname", "givenName", "mail"], + "required_attributes": [ + "surname", + "givenName", + "mail", + ], } } @@ -708,7 +749,7 @@ Example:: "sp": { "want_response_signed": False, "want_assertions_signed": False, - "want_assertions_or_response_signed": True + "want_assertions_or_response_signed": True, } } @@ -757,11 +798,13 @@ Example:: "service": "idp": { - "endpoints" : { - "single_sign_on_service" : [ - ("http://localhost:8088/sso", BINDING_HTTP_REDIRECT)], + "endpoints": { + "single_sign_on_service": [ + ("http://localhost:8088/sso", BINDING_HTTP_REDIRECT), + ], "single_logout_service": [ - ("http://localhost:8088/slo", BINDING_HTTP_REDIRECT)] + ("http://localhost:8088/slo", BINDING_HTTP_REDIRECT), + ], }, }, }, @@ -810,9 +853,9 @@ virtual_organization Gives information about common identifiers for virtual_organizations:: - "virtual_organization" : { - "urn:mace:example.com:it:tek":{ - "nameid_format" : "urn:oid:1.3.6.1.4.1.1466.115.121.1.15-NameID", + "virtual_organization": { + "urn:mace:example.com:it:tek": { + "nameid_format": "urn:oid:1.3.6.1.4.1.1466.115.121.1.15-NameID", "common_identifier": "umuselin", } }, @@ -830,35 +873,38 @@ We start with a simple but fairly complete Service provider configuration:: from saml2 import BINDING_HTTP_REDIRECT CONFIG = { - "entityid" : "http://example.com/sp/metadata.xml", + "entityid": "http://example.com/sp/metadata.xml", "service": { - "sp":{ - "name" : "Example SP", - "endpoints":{ + "sp": { + "name": "Example SP", + "endpoints": { "assertion_consumer_service": ["http://example.com/sp"], - "single_logout_service" : [("http://example.com/sp/slo", - BINDING_HTTP_REDIRECT)], + "single_logout_service": [ + ("http://example.com/sp/slo", BINDING_HTTP_REDIRECT), + ], }, } }, - "key_file" : "./mykey.pem", - "cert_file" : "./mycert.pem", - "xmlsec_binary" : "/usr/local/bin/xmlsec1", + "key_file": "./mykey.pem", + "cert_file": "./mycert.pem", + "xmlsec_binary": "/usr/local/bin/xmlsec1", "delete_tmpfiles": True, "attribute_map_dir": "./attributemaps", "metadata": { "local": ["idp.xml"] } "organization": { - "display_name":["Example identities"] + "display_name": ["Example identities"] } - "contact_person": [{ - "givenname": "Roland", - "surname": "Hedberg", - "phone": "+46 90510", - "mail": "roland@example.com", - "type": "technical", - }] + "contact_person": [ + { + "givenname": "Roland", + "surname": "Hedberg", + "phone": "+46 90510", + "mail": "roland@example.com", + "type": "technical", + }, + ] } This is the typical setup for an SP. @@ -872,45 +918,51 @@ A slightly more complex configuration:: from saml2 import BINDING_HTTP_REDIRECT CONFIG = { - "entityid" : "http://sp.example.com/metadata.xml", + "entityid": "http://sp.example.com/metadata.xml", "service": { - "sp":{ - "name" : "Example SP", - "endpoints":{ + "sp": { + "name": "Example SP", + "endpoints": { "assertion_consumer_service": ["http://sp.example.com/"], - "single_logout_service" : [("http://sp.example.com/slo", - BINDING_HTTP_REDIRECT)], + "single_logout_service": [ + ("http://sp.example.com/slo", BINDING_HTTP_REDIRECT), + ], }, "subject_data": ("memcached", "localhost:12121"), - "virtual_organization" : { - "urn:mace:example.com:it:tek":{ - "nameid_format" : "urn:oid:1.3.6.1.4.1.1466.115.121.1.15-NameID", + "virtual_organization": { + "urn:mace:example.com:it:tek": { + "nameid_format": "urn:oid:1.3.6.1.4.1.1466.115.121.1.15-NameID", "common_identifier": "eduPersonPrincipalName", } }, } }, - "key_file" : "./mykey.pem", - "cert_file" : "./mycert.pem", - "xmlsec_binary" : "/usr/local/bin/xmlsec1", + "key_file": "./mykey.pem", + "cert_file": "./mycert.pem", + "xmlsec_binary": "/usr/local/bin/xmlsec1", "delete_tmpfiles": True, - "metadata" : { + "metadata": { "local": ["example.xml"], - "remote": [{ - "url":"https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral2&set=saml2", - "cert":"kalmar2.pem"}] + "remote": [ + { + "url":"https://kalmar2.org/simplesaml/module.php/aggregator/?id=kalmarcentral2&set=saml2", + "cert":"kalmar2.pem", + } + ] }, - "attribute_maps" : "attributemaps", + "attribute_maps": "attributemaps", "organization": { - "display_name":["Example identities"] + "display_name": ["Example identities"] } - "contact_person": [{ - "givenname": "Roland", - "surname": "Hedberg", - "phone": "+46 90510", - "mail": "roland@example.com", - "type": "technical", - }] + "contact_person": [ + { + "givenname": "Roland", + "surname": "Hedberg", + "phone": "+46 90510", + "mail": "roland@example.com", + "type": "technical", + }, + ] } Uses metadata files, both local and remote, and will talk to whatever diff --git a/src/saml2/mdstore.py b/src/saml2/mdstore.py index 36891abb..32778af9 100644 --- a/src/saml2/mdstore.py +++ b/src/saml2/mdstore.py @@ -33,6 +33,10 @@ from saml2.s_utils import UnknownSystemEntity from saml2.sigver import split_len from saml2.validate import valid_instance from saml2.time_util import valid +from saml2.time_util import instant +from saml2.time_util import add_duration +from saml2.time_util import before +from saml2.time_util import str_to_time from saml2.validate import NotValid from saml2.sigver import security_context from saml2.extension.mdattr import NAMESPACE as NS_MDATTR @@ -72,6 +76,11 @@ ENTITY_CATEGORY = "http://macedir.org/entity-category" ENTITY_CATEGORY_SUPPORT = "http://macedir.org/entity-category-support" ASSURANCE_CERTIFICATION = "urn:oasis:names:tc:SAML:attribute:assurance-certification" +SAML_METADATA_CONTENT_TYPE = "application/samlmetadata+xml" +DEFAULT_FRESHNESS_PERIOD = "P0Y0M0DT12H0M0S" + + + REQ2SRV = { # IDP "authn_request": "single_sign_on_service", @@ -664,22 +673,21 @@ class InMemoryMetaData(MetaData): def parse_and_check_signature(self, txt): self.parse(txt) - if self.cert: - if not self.signed(): - return True - - node_name = self.node_name \ - or "%s:%s" % (md.EntitiesDescriptor.c_namespace, - md.EntitiesDescriptor.c_tag) + if not self.cert: + return True - if self.security.verify_signature( - txt, node_name=node_name, cert_file=self.cert): - return True - else: - return False - else: + if not self.signed(): return True + fallback_name = "{ns}:{tag}".format( + ns=md.EntitiesDescriptor.c_namespace, tag=md.EntitiesDescriptor.c_tag + ) + node_name = self.node_name or fallback_name + + return self.security.verify_signature( + txt, node_name=node_name, cert_file=self.cert + ) + class MetaDataFile(InMemoryMetaData): """ @@ -805,9 +813,6 @@ class MetaDataMD(InMemoryMetaData): self.entity[key] = item -SAML_METADATA_CONTENT_TYPE = 'application/samlmetadata+xml' - - class MetaDataMDX(InMemoryMetaData): """ Uses the MDQ protocol to fetch entity information. @@ -817,11 +822,12 @@ class MetaDataMDX(InMemoryMetaData): @staticmethod def sha1_entity_transform(entity_id): - return "{{sha1}}{}".format( - hashlib.sha1(entity_id.encode("utf-8")).hexdigest()) + entity_id_sha1 = hashlib.sha1(entity_id.encode("utf-8")).hexdigest() + transform = "{{sha1}}{digest}".format(digest=entity_id_sha1) + return transform def __init__(self, url=None, security=None, cert=None, - entity_transform=None, **kwargs): + entity_transform=None, freshness_period=None, **kwargs): """ :params url: mdx service url :params security: SecurityContext() @@ -831,6 +837,8 @@ class MetaDataMDX(InMemoryMetaData): hash) the entity id. It is applied to the entity id before it is concatenated with the request URL sent to the MDX server. Defaults to sha1 transformation. + :params freshness_period: a duration in the format described at + https://www.w3.org/TR/xmlschema-2/#duration """ super(MetaDataMDX, self).__init__(None, **kwargs) if not url: @@ -845,6 +853,8 @@ class MetaDataMDX(InMemoryMetaData): self.cert = cert self.security = security + self.freshness_period = freshness_period or DEFAULT_FRESHNESS_PERIOD + self.expiration_date = {} # We assume that the MDQ server will return a single entity # described by a single <EntityDescriptor> element. The protocol @@ -852,28 +862,53 @@ class MetaDataMDX(InMemoryMetaData): # <EntitiesDescriptor> element but we will not currently support # that use case since it is unlikely to be leveraged for most # flows. - self.node_name = "%s:%s" % (md.EntityDescriptor.c_namespace, - md.EntityDescriptor.c_tag) + self.node_name = "{ns}:{tag}".format( + ns=md.EntityDescriptor.c_namespace, tag=md.EntityDescriptor.c_tag + ) def load(self, *args, **kwargs): # Do nothing pass + def _fetch_metadata(self, item): + mdx_url = "{url}/entities/{id}".format( + url=self.url, id=self.entity_transform(item) + ) + + response = requests.get(mdx_url, headers={"Accept": SAML_METADATA_CONTENT_TYPE}) + if response.status_code != 200: + error_msg = "Fething {item}: Got response status {status}".format( + item=item, status=response.status_code + ) + logger.info(error_msg) + raise KeyError(error_msg) + + _txt = response.content + if not self.parse_and_check_signature(_txt): + error_msg = "Fething {item}: invalid signature".format( + item=item, status=response.status_code + ) + logger.info(error_msg) + raise KeyError(error_msg) + + curr_time = str_to_time(instant()) + self.expiration_date[item] = add_duration(curr_time, self.freshness_period) + return self.entity[item] + + def _is_metadata_fresh(self, item): + return before(self.expiration_date[item]) + def __getitem__(self, item): - try: - return self.entity[item] - except KeyError: - mdx_url = "%s/entities/%s" % (self.url, self.entity_transform(item)) - response = requests.get(mdx_url, headers={ - 'Accept': SAML_METADATA_CONTENT_TYPE}) - if response.status_code == 200: - _txt = response.content - - if self.parse_and_check_signature(_txt): - return self.entity[item] - else: - logger.info("Response status: %s", response.status_code) - raise KeyError + if item not in self.entity: + entity = self._fetch_metadata(item) + elif not self._is_metadata_fresh(item): + msg = "Metadata for {} have expired; refreshing metadata".format(item) + logger.info(msg) + old_entity = self.entity.pop(item) + entity = self._fetch_metadata(item) + else: + entity = self.entity[item] + return entity def single_sign_on_service(self, entity_id, binding=None, typ="idpsso"): if binding is None: @@ -960,9 +995,11 @@ class MetadataStore(MetaData): key = kwargs['url'] url = kwargs['url'] cert = kwargs.get('cert') + freshness_period = kwargs.get('freshness_period', None) security = self.security entity_transform = kwargs.get('entity_transform', None) - _md = MetaDataMDX(url, security, cert, entity_transform) + _md = MetaDataMDX(url, security, cert, entity_transform, + freshness_period=freshness_period) else: key = args[1] url = args[1] diff --git a/src/saml2/time_util.py b/src/saml2/time_util.py index 62ac3cc8..efe57144 100644 --- a/src/saml2/time_util.py +++ b/src/saml2/time_util.py @@ -140,19 +140,20 @@ def add_duration(tid, duration): carry = f_quotient(temp, 60) # hours temp = tid.tm_hour + dur["tm_hour"] + carry - hour = modulo(temp, 60) - carry = f_quotient(temp, 60) + hour = modulo(temp, 24) + carry = f_quotient(temp, 24) # days - if dur["tm_mday"] > maximum_day_in_month_for(year, month): + if tid.tm_mday > maximum_day_in_month_for(year, month): temp_days = maximum_day_in_month_for(year, month) - elif dur["tm_mday"] < 1: + elif tid.tm_mday < 1: temp_days = 1 else: - temp_days = dur["tm_mday"] - days = temp_days + tid.tm_mday + carry + temp_days = tid.tm_mday + days = temp_days + dur["tm_mday"] + carry while True: if days < 1: - pass + days = days + maximum_day_in_month_for(year, month - 1) + carry = -1 elif days > maximum_day_in_month_for(year, month): days -= maximum_day_in_month_for(year, month) carry = 1 diff --git a/tests/test_10_time_util.py b/tests/test_10_time_util.py index f0608939..1c1f8198 100644 --- a/tests/test_10_time_util.py +++ b/tests/test_10_time_util.py @@ -92,7 +92,7 @@ def test_parse_duration_n(): assert d == _val def test_add_duration_1(): - #2000-01-12T12:13:14Z P1Y3M5DT7H10M3S 2001-04-17T19:23:17Z + #2000-01-12T12:13:14Z P1Y3M5DT7H10M3S 2001-04-17T19:23:17Z t = add_duration(str_to_time("2000-01-12T12:13:14Z"), "P1Y3M5DT7H10M3S") assert t.tm_year == 2001 assert t.tm_mon == 4 @@ -107,7 +107,7 @@ def test_add_duration_2(): t = add_duration(str_to_time("2000-01-12T00:00:00Z"), "PT33H") assert t.tm_year == 2000 assert t.tm_mon == 1 - assert t.tm_mday == 14 + assert t.tm_mday == 13 assert t.tm_hour == 9 assert t.tm_min == 0 assert t.tm_sec == 0 @@ -119,7 +119,7 @@ def test_str_to_time(): #t = time.mktime(str_to_time("2000-01-12T00:00:00Z")) #assert t == 947631600.0 #TODO: add something to show how this time was arrived at - # do this as an external method in the + # do this as an external method in the assert t == 947635200 # some IdPs omit the trailing Z, and SAML spec is unclear if it is actually required t = calendar.timegm(str_to_time("2000-01-12T00:00:00")) diff --git a/tests/test_30_mdstore.py b/tests/test_30_mdstore.py index c77293bb..59dc7da6 100644 --- a/tests/test_30_mdstore.py +++ b/tests/test_30_mdstore.py @@ -4,8 +4,11 @@ import datetime import os import re from collections import OrderedDict +from unittest.mock import Mock from unittest.mock import patch +import responses + from six.moves.urllib import parse from saml2.config import Config @@ -26,8 +29,6 @@ from saml2.attribute_converter import d_to_local_name from saml2.s_utils import UnknownPrincipal from pathutils import full_path -import responses - TESTS_DIR = os.path.dirname(__file__) @@ -334,6 +335,60 @@ def test_mdx_single_sign_on_service(): assert sso_loc[0]["location"] == "http://xenosmilus.umdc.umu.se/simplesaml/saml2/idp/metadata.php" +@responses.activate +def test_mdx_metadata_freshness_period_not_expired(): + """Ensure that metadata is not refreshed if not expired.""" + + entity_id = "http://xenosmilus.umdc.umu.se/simplesaml/saml2/idp/metadata.php" + url = "http://mdx.example.com/entities/{}".format( + parse.quote_plus(MetaDataMDX.sha1_entity_transform(entity_id)) + ) + + responses.add( + responses.GET, + url, + body=TEST_METADATA_STRING, + status=200, + content_type=SAML_METADATA_CONTENT_TYPE, + ) + + mdx = MetaDataMDX("http://mdx.example.com", freshness_period="P0Y0M0DT0H2M0S") + mdx._is_metadata_fresh = Mock(return_value=True) + + mdx.single_sign_on_service(entity_id, BINDING_HTTP_REDIRECT) + assert entity_id in mdx.entity + + mdx.single_sign_on_service(entity_id, BINDING_HTTP_REDIRECT) + assert len(responses.calls) == 1 + + +@responses.activate +def test_mdx_metadata_freshness_period_expired(): + """Ensure that metadata is not refreshed if not expired.""" + + entity_id = "http://xenosmilus.umdc.umu.se/simplesaml/saml2/idp/metadata.php" + url = "http://mdx.example.com/entities/{}".format( + parse.quote_plus(MetaDataMDX.sha1_entity_transform(entity_id)) + ) + + responses.add( + responses.GET, + url, + body=TEST_METADATA_STRING, + status=200, + content_type=SAML_METADATA_CONTENT_TYPE, + ) + + mdx = MetaDataMDX("http://mdx.example.com", freshness_period="P0Y0M0DT0H2M0S") + mdx._is_metadata_fresh = Mock(return_value=False) + + mdx.single_sign_on_service(entity_id, BINDING_HTTP_REDIRECT) + assert entity_id in mdx.entity + + mdx.single_sign_on_service(entity_id, BINDING_HTTP_REDIRECT) + assert len(responses.calls) == 2 + + # pyff-test not available # def test_mdx_service(): # sec_config.xmlsec_binary = sigver.get_xmlsec_binary(["/opt/local/bin"]) |