From 9fe88e6fe7d968beb5940b886792c8114ccc7e50 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 30 Apr 2015 17:42:08 -0700 Subject: [PATCH 1/8] SAML2 backend using OneLogin's python-saml --- social/backends/saml.py | 277 +++++++++++++++++++++++++++ social/strategies/base.py | 20 ++ social/strategies/django_strategy.py | 20 ++ 3 files changed, 317 insertions(+) create mode 100644 social/backends/saml.py diff --git a/social/backends/saml.py b/social/backends/saml.py new file mode 100644 index 000000000..135cf4086 --- /dev/null +++ b/social/backends/saml.py @@ -0,0 +1,277 @@ +""" +Backend for SAML 2.0 support + +Terminology: + +"Service Provider" (SP): Your web app +"Identity Provider" (IdP): The third-party site that is authenticating users via SAML +""" +from onelogin.saml2.auth import OneLogin_Saml2_Auth +from onelogin.saml2.settings import OneLogin_Saml2_Settings +from social.backends.base import BaseAuth +from social.exceptions import AuthFailed + +# Helpful constants: +OID_COMMON_NAME = "urn:oid:2.5.4.3" +OID_EDU_PERSON_PRINCIPAL_NAME = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6" +OID_GIVEN_NAME = "urn:oid:2.5.4.42" +OID_MAIL = "urn:oid:0.9.2342.19200300.100.1.3" +OID_SURNAME = "urn:oid:2.5.4.4" +OID_USERID = "urn:oid:0.9.2342.19200300.100.1.1" + + +class SAMLIdentityProvider(object): + """ + Wrapper around configuration for a SAML Identity provider + """ + + def __init__(self, name, **kwargs): + """ Load and parse configuration """ + self.name = name + assert self.name.isalnum() # If 'name' contained a colon, it would affect our UID mangling + self.conf = kwargs + + def get_user_permanent_id(self, attributes): + """ + The most important method: Get a permanent, unique identifier for this user from the + attributes supplied by the IdP. + + If you want to use the NameID, it's available via attributes['name_id'] + """ + return attributes[self.conf.get('user_permanent_id', OID_USERID)][0] + + # Attributes processing: + def get_user_details(self, attributes): + """ + Given the SAML attributes extracted from the SSO response, get the user data like name. + """ + return { + 'fullname': self.get_attr(attributes, 'attr_full_name', OID_COMMON_NAME), + 'first_name': self.get_attr(attributes, 'attr_first_name', OID_GIVEN_NAME), + 'last_name': self.get_attr(attributes, 'attr_last_name', OID_SURNAME), + 'username': self.get_attr(attributes, 'attr_username', OID_USERID), + 'email': self.get_attr(attributes, 'attr_email', OID_MAIL), + } + + def get_attr(self, attributes, conf_key, default_attribute): + """ + Internal helper method. + Get the attribute 'default_attribute' out of the attributes, unless self.conf[conf_key] + overrides the default by specifying another attribute to use. + """ + key = self.conf.get(conf_key, default_attribute) + return attributes[key][0] if key in attributes else None + + @property + def entity_id(self): + """ Get the entity ID for this IdP """ + return self.conf['entity_id'] # Required. e.g. "https://idp.testshib.org/idp/shibboleth" + + @property + def sso_url(self): + """ Get the SSO URL for this IdP """ + return self.conf['url'] # Required. e.g. "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" + + @property + def sso_binding(self): + """ Get the method used to submit our request to the SSO URL """ + return self.conf.get('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect') + + @property + def x509cert(self): + """ X.509 Public Key Certificate for this IdP """ + return self.conf['x509cert'] + + @property + def saml_config_dict(self): + """ Get the IdP configuration dict in the format required by python-saml """ + return { + "entityId": self.entity_id, + "singleSignOnService": { + "url": self.sso_url, + "binding": self.sso_binding, + }, + "x509cert": self.x509cert, + } + + +class DummySAMLIdentityProvider(SAMLIdentityProvider): + """ + A placeholder IdP used when we must specify something, e.g. when generating SP metadata. + + If OneLogin_Saml2_Auth is modified to not always require IdP config, this can be removed. + """ + def __init__(self): + super(DummySAMLIdentityProvider, self).__init__( + "dummy", + entity_id="https://dummy.none/saml2", + url="https://dummy.none/SSO", + x509cert='', + ) + + +class SAMLAuth(BaseAuth): + """ + PSA Backend that implements SAML 2.0 Service Provider (SP) functionality. + + Unlike all of the other backends, this one can be configured to work with + many identity providers (IdPs). For example, a University that belongs to a + Shibboleth federation may support authentication via ~100 partner + universities. Also, the IdP configuration can be changed at runtime if you + require that functionality - just subclass this and override `get_idp()`. + + Several settings are required. Here's an example: + + SOCIAL_AUTH_SAML_SP_ENTITY_ID = "https://saml.example.com/" + SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = "... X.509 certificate string ..." + SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = "... private key ..." + SOCIAL_AUTH_SAML_ORG_INFO = { + "en-US": {"name": "example", "displayname": "Example Inc.", "url": "http://example.com", }, + } + SOCIAL_AUTH_SAML_TECHNICAL_CONTACT = {"givenName": "Tech Gal", "emailAddress": "technical@example.com", } + SOCIAL_AUTH_SAML_SUPPORT_CONTACT = {"givenName": "Support Guy", "emailAddress": "support@example.com", } + SOCIAL_AUTH_SAML_ENABLED_IDPS = { + "testshib": { + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", + "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0B ... 8Bbnl+ev0peYzxFyF5sQA==", + } + } + + Optional settings: + SOCIAL_AUTH_SAML_SP_EXTRA = {} + SOCIAL_AUTH_SAML_SECURITY_CONFIG = {} + SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS = [] + """ + name = "saml" + + def get_idp(self, idp_name): + """ Given the name of an IdP, get a SAMLIdentityProvider instance """ + idp_config = self.setting("ENABLED_IDPS")[idp_name] + return SAMLIdentityProvider(idp_name, **idp_config) + + def generate_saml_config(self, idp): + """ + Generate the configuration required to instantiate OneLogin_Saml2_Auth + """ + # The shared absolute URL that all IdPs redirect back to - this is specified in our metadata.xml: + abs_completion_url = self.redirect_uri + + config = { + "contactPerson": { + "technical": self.setting("TECHNICAL_CONTACT"), + "support": self.setting("SUPPORT_CONTACT"), + }, + "debug": True, + "idp": idp.saml_config_dict, + "organization": self.setting("ORG_INFO"), + "security": { + 'metadataValidUntil': '', + 'metadataCacheDuration': 'P10D', # metadata valid for ten days + }, + "sp": { + "assertionConsumerService": { + "url": abs_completion_url, + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + }, + "entityId": self.setting("SP_ENTITY_ID"), + "NameIDFormats": self.setting("SP_NAMEID_FORMATS", []), + "x509cert": self.setting("SP_PUBLIC_CERT"), + "privateKey": self.setting("SP_PRIVATE_KEY"), + }, + "strict": True, # We must force strict mode - for security + } + config["security"].update(self.setting("SECURITY_CONFIG", {})) + config["sp"].update(self.setting("SP_EXTRA", {})) + return config + + def generate_metadata_xml(self): + """ + Helper method that can be used from your web app to generate the XML metadata required + to link your web app as a Service Provider with each IdP you wish to use. + + Returns (metadata XML string, list of errors) + + Example usage (Django): + from social.apps.django_app.utils import load_strategy, load_backend + def saml_metadata_view(request): + complete_url = reverse('social:complete', args=("saml", )) + saml_backend = load_backend(load_strategy(request), "saml", complete_url) + metadata, errors = saml_backend.generate_metadata_xml() + if not errors: + return HttpResponse(content=metadata, content_type='text/xml') + return HttpResponseServerError(content=', '.join(errors)) + """ + idp = DummySAMLIdentityProvider() # python-saml requires us to specify something here even though it's not used + config = self.generate_saml_config(idp) + saml_settings = OneLogin_Saml2_Settings(config) + metadata = saml_settings.get_sp_metadata() + errors = saml_settings.validate_metadata(metadata) + return metadata, errors + + def _create_saml_auth(self, idp_name): + """ + Get an instance of OneLogin_Saml2_Auth + """ + config = self.generate_saml_config(idp=self.get_idp(idp_name)) + request_info = { + 'https': 'on' if self.strategy.request_is_secure() else 'off', + 'http_host': self.strategy.request_host(), + 'script_name': self.strategy.request_path(), + 'server_port': self.strategy.request_port(), + 'get_data': self.strategy.request_get(), + 'post_data': self.strategy.request_post(), + } + return OneLogin_Saml2_Auth(request_info, config) + + def auth_url(self): + """ Get the URL to which we must redirect in order to authenticate the user """ + idp_name = self.strategy.request_data()['idp'] + auth = self._create_saml_auth(idp_name) + # Below, return_to sets the RelayState, which can contain arbitrary data. + # We use it to store the specific SAML IdP backend name, since we combine + # many backends to a single URL. + return auth.login(return_to=idp_name) + + def get_user_details(self, response): + """ + Get user details like full name, email, etc. from the response - see auth_complete + """ + idp = self.get_idp(response['idp_name']) + return idp.get_user_details(response['attributes']) + + def get_user_id(self, details, response): + """ + Get the permanent ID for this user from the response. + We prefix each ID with the name of the IdP so that we can connect multiple IdPs to this + user. + """ + idp = self.get_idp(response['idp_name']) + uid = idp.get_user_permanent_id(response['attributes']) + return '{}:{}'.format(idp.name, uid) + + def auth_complete(self, *args, **kwargs): + """ + The user has been redirected back from the IdP and we should now log them in, if + everything checks out. + """ + idp_name = self.strategy.request_data()['RelayState'] + auth = self._create_saml_auth(idp_name) + auth.process_response() + errors = auth.get_errors() + if errors or not auth.is_authenticated(): + reason = auth.get_last_error_reason() + raise AuthFailed(self, 'SAML login failed: {} ({})'.format(errors, reason)) + + attributes = auth.get_attributes() + attributes['name_id'] = auth.get_nameid() + + response = { + 'idp_name': idp_name, + 'attributes': attributes, + 'session_index': auth.get_session_index(), + } + + kwargs.update({'response': response, 'backend': self}) + + return self.strategy.authenticate(*args, **kwargs) diff --git a/social/strategies/base.py b/social/strategies/base.py index f2273b972..09ef9fa27 100644 --- a/social/strategies/base.py +++ b/social/strategies/base.py @@ -188,3 +188,23 @@ def session_pop(self, name): def build_absolute_uri(self, path=None): """Build absolute URI with given (optional) path""" raise NotImplementedError('Implement in subclass') + + def request_is_secure(self): + """ Is the request using HTTPS? """ + raise NotImplementedError('Implement in subclass') + + def request_path(self): + """ path of the current request """ + raise NotImplementedError('Implement in subclass') + + def request_port(self): + """ Port in use for this request """ + raise NotImplementedError('Implement in subclass') + + def request_get(self): + """ Request GET data """ + raise NotImplementedError('Implement in subclass') + + def request_post(self): + """ Request POST data """ + raise NotImplementedError('Implement in subclass') diff --git a/social/strategies/django_strategy.py b/social/strategies/django_strategy.py index 7e80f03fa..b3b66b791 100644 --- a/social/strategies/django_strategy.py +++ b/social/strategies/django_strategy.py @@ -53,6 +53,26 @@ def request_host(self): if self.request: return self.request.get_host() + def request_is_secure(self): + """ Is the request using HTTPS? """ + return self.request.is_secure() + + def request_path(self): + """ path of the current request """ + return self.request.path + + def request_port(self): + """ Port in use for this request """ + return self.request.META['SERVER_PORT'] + + def request_get(self): + """ Request GET data """ + return self.request.GET.copy() + + def request_post(self): + """ Request POST data """ + return self.request.POST.copy() + def redirect(self, url): return redirect(url) From 05ca867f1166fdb63b9fb838a0b8e44b7d48d11d Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Fri, 8 May 2015 00:47:07 -0700 Subject: [PATCH 2/8] Tests for SAML backend --- .travis.yml | 3 + social/tests/actions/test_disconnect.py | 13 ++- social/tests/backends/data/saml_config.json | 26 +++++ social/tests/backends/data/saml_response.txt | 1 + social/tests/backends/test_saml.py | 104 +++++++++++++++++++ social/tests/models.py | 2 +- social/tests/strategy.py | 20 ++++ 7 files changed, 167 insertions(+), 2 deletions(-) create mode 100644 social/tests/backends/data/saml_config.json create mode 100644 social/tests/backends/data/saml_response.txt create mode 100644 social/tests/backends/test_saml.py diff --git a/.travis.yml b/.travis.yml index f7aca6f7e..d9380b2ce 100644 --- a/.travis.yml +++ b/.travis.yml @@ -17,6 +17,9 @@ matrix: env: - REQUIREMENTS=requirements-python3.txt - TEST_REQUIREMENTS=social/tests/requirements-python3.txt +before_install: + - sudo apt-get update -qq + - sudo apt-get install -y libxmlsec1-dev swig install: - "python setup.py -q install" - "travis_retry pip install -r $REQUIREMENTS" diff --git a/social/tests/actions/test_disconnect.py b/social/tests/actions/test_disconnect.py index 328ad8d0c..ef89d5939 100644 --- a/social/tests/actions/test_disconnect.py +++ b/social/tests/actions/test_disconnect.py @@ -6,7 +6,7 @@ from social.exceptions import NotAllowedToDisconnect from social.utils import parse_qs -from social.tests.models import User +from social.tests.models import User, TestUserSocialAuth from social.tests.actions.actions import BaseActionTest @@ -24,6 +24,17 @@ def test_disconnect(self): do_disconnect(self.backend, user) self.assertEqual(len(user.social), 0) + def test_disconnect_with_association_id(self): + self.do_login() + user = User.get(self.expected_username) + user.password = 'password' + association_id = user.social[0].id + second_usa = TestUserSocialAuth(user, user.social[0].provider, "uid2") + self.assertEqual(len(user.social), 2) + do_disconnect(self.backend, user, association_id) + self.assertEqual(len(user.social), 1) + self.assertEqual(user.social[0], second_usa) + def test_disconnect_with_partial_pipeline(self): self.strategy.set_settings({ 'SOCIAL_AUTH_DISCONNECT_PIPELINE': ( diff --git a/social/tests/backends/data/saml_config.json b/social/tests/backends/data/saml_config.json new file mode 100644 index 000000000..5c119e6a7 --- /dev/null +++ b/social/tests/backends/data/saml_config.json @@ -0,0 +1,26 @@ +{ + "SOCIAL_AUTH_SAML_SP_ENTITY_ID": "https://github.com/omab/python-social-auth/saml-test", + "SOCIAL_AUTH_SAML_SP_PUBLIC_CERT": "MIICsDCCAhmgAwIBAgIJAO7BwdjDZcUWMA0GCSqGSIb3DQEBBQUAMEUxCzAJBgNVBAYTAkNBMRkwFwYDVQQIExBCcml0aXNoIENvbHVtYmlhMRswGQYDVQQKExJweXRob24tc29jaWFsLWF1dGgwHhcNMTUwNTA4MDc1ODQ2WhcNMjUwNTA3MDc1ODQ2WjBFMQswCQYDVQQGEwJDQTEZMBcGA1UECBMQQnJpdGlzaCBDb2x1bWJpYTEbMBkGA1UEChMScHl0aG9uLXNvY2lhbC1hdXRoMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCq3g1Cl+3uR5vCnN4HbgjTg+m3nHhteEMyb++ycZYre2bxUfsshER6x33l23tHckRYwm7MdBbrp3LrVoiOCdPblTml1IhEPTCwKMhBKvvWqTvgfcSSnRzAWkLlQYSusayyZK4n9qcYkV5MFni1rbjx+Mr5aOEmb5u33amMKLwSTwIDAQABo4GnMIGkMB0GA1UdDgQWBBRRiBR6zS66fKVokp0yJHbgv3RYmjB1BgNVHSMEbjBsgBRRiBR6zS66fKVokp0yJHbgv3RYmqFJpEcwRTELMAkGA1UEBhMCQ0ExGTAXBgNVBAgTEEJyaXRpc2ggQ29sdW1iaWExGzAZBgNVBAoTEnB5dGhvbi1zb2NpYWwtYXV0aIIJAO7BwdjDZcUWMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEFBQADgYEAJwsMU3YSaybVjuJ8US0fUhlPOlM40QFCGL4vB3TEbb24Mq8HrjUwrU0JFPGls9a2OYzN2B3e35NorMuxs+grGtr2yP6LvuX+nV6A93wb4ooGHoGfC7VLlyxSSns937SS5R1pzQ4gWzZma2KGWKICWph5zQ0ARVhL63967mGLmoI=", + "SOCIAL_AUTH_SAML_SP_PRIVATE_KEY": "MIICXgIBAAKBgQCq3g1Cl+3uR5vCnN4HbgjTg+m3nHhteEMyb++ycZYre2bxUfsshER6x33l23tHckRYwm7MdBbrp3LrVoiOCdPblTml1IhEPTCwKMhBKvvWqTvgfcSSnRzAWkLlQYSusayyZK4n9qcYkV5MFni1rbjx+Mr5aOEmb5u33amMKLwSTwIDAQABAoGBAIHAg6NJSiYC/NYpVzWfKlasuoNy78R5adXYSNZiCR5V5FNm5OzmODZgXUt6g0A7FomshIT/txQWoV7y5FmwPs8n13JY3Hdt4tJ6MHw2feLo710+OEp9VBQus3JsB2F8ONYrGvs00hPPL7h5av/rzTdE8F67YM1mSgeg7xEF6BghAkEA12OOqSzp2MLTNY7PqOaLDzy4aAMVNN3Ntv2jBN0jq7s1b5ilQ2PGkLwdtkicq/VZcRyUqVbZbMwz05II3nqx3wJBAMsVhRQ5sdFCRBzEbSAm2YEJaFh5u6QT3+zWHMFpPJRnaBAWz3RXKEnleJ+DS2Xz1Jm6ZrmLdZiwMx/8dK5rDZECQQC7GTdWi7ZC3dIcpwaKIGHRhZxmda8ZMkc9Wwwd8H7I8aFUZFPCu0xEc7SXoHHACit8zyfwBYpvMN8gPK3JnOkfAkEAsUSpk0wBMT38one7IZOHzCDgGkq4RbKrhdon45Pus0PIDDM9BrqFimtpbSN4DxhVfZK91DwtfAhhuAvv9cewYQJAPMhpAqv3PBGYmtRDUlWXJQv2JRJJkrvbbqgBed2OX5RRgj5V3SR6PBhLbcTZ+q+1tdPkMFzZo5U6MN5m/6oXvQ==", + "SOCIAL_AUTH_SAML_ORG_INFO": { + "en-US": {"name": "psa", "displayname": "PSA", "url": "https://github.com/omab/python-social-auth/"} + }, + "SOCIAL_AUTH_SAML_TECHNICAL_CONTACT": + {"givenName": "Tech Gal", "emailAddress": "technical@example.com"}, + "SOCIAL_AUTH_SAML_SUPPORT_CONTACT": + {"givenName": "Support Guy", "emailAddress": "support@example.com"}, + "SOCIAL_AUTH_SAML_ENABLED_IDPS": { + "testshib": { + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", + "x509cert": "MIIEDjCCAvagAwIBAgIBADANBgkqhkiG9w0BAQUFADBnMQswCQYDVQQGEwJVUzEVMBMGA1UECBMMUGVubnN5bHZhbmlhMRMwEQYDVQQHEwpQaXR0c2J1cmdoMREwDwYDVQQKEwhUZXN0U2hpYjEZMBcGA1UEAxMQaWRwLnRlc3RzaGliLm9yZzAeFw0wNjA4MzAyMTEyMjVaFw0xNjA4MjcyMTEyMjVaMGcxCzAJBgNVBAYTAlVTMRUwEwYDVQQIEwxQZW5uc3lsdmFuaWExEzARBgNVBAcTClBpdHRzYnVyZ2gxETAPBgNVBAoTCFRlc3RTaGliMRkwFwYDVQQDExBpZHAudGVzdHNoaWIub3JnMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArYkCGuTmJp9eAOSGHwRJo1SNatB5ZOKqDM9ysg7CyVTDClcpu93gSP10nH4gkCZOlnESNgttg0r+MqL8tfJC6ybddEFB3YBo8PZajKSe3OQ01Ow3yT4I+Wdg1tsTpSge9gEz7SrC07EkYmHuPtd71CHiUaCWDv+xVfUQX0aTNPFmDixzUjoYzbGDrtAyCqA8f9CN2txIfJnpHE6q6CmKcoLADS4UrNPlhHSzd614kR/JYiks0K4kbRqCQF0Dv0P5Di+rEfefC6glV8ysC8dB5/9nb0yh/ojRuJGmgMWHgWk6h0ihjihqiu4jACovUZ7vVOCgSE5Ipn7OIwqd93zp2wIDAQABo4HEMIHBMB0GA1UdDgQWBBSsBQ869nh83KqZr5jArr4/7b+QazCBkQYDVR0jBIGJMIGGgBSsBQ869nh83KqZr5jArr4/7b+Qa6FrpGkwZzELMAkGA1UEBhMCVVMxFTATBgNVBAgTDFBlbm5zeWx2YW5pYTETMBEGA1UEBxMKUGl0dHNidXJnaDERMA8GA1UEChMIVGVzdFNoaWIxGTAXBgNVBAMTEGlkcC50ZXN0c2hpYi5vcmeCAQAwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQUFAAOCAQEAjR29PhrCbk8qLN5MFfSVk98t3CT9jHZoYxd8QMRLI4j7iYQxXiGJTT1FXs1nd4Rha9un+LqTfeMMYqISdDDI6tv8iNpkOAvZZUosVkUo93pv1T0RPz35hcHHYq2yee59HJOco2bFlcsH8JBXRSRrJ3Q7Eut+z9uo80JdGNJ4/SJy5UorZ8KazGj16lfJhOBXldgrhppQBb0Nq6HKHguqmwRfJ+WkxemZXzhediAjGeka8nz8JjwxpUjAiSWYKLtJhGEaTqCYxCCX2Dw+dOTqUzHOZ7WKv4JXPK5G/Uhr8K/qhmFT2nIQi538n6rVYLeWj8Bbnl+ev0peYzxFyF5sQA==" + }, + "other": { + "entity_id": "https://unused.saml.example.com", + "singleSignOnService": { + "url": "https://unused.saml.example.com/SAML2/Redirect/SSO", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" + } + } + } +} diff --git a/social/tests/backends/data/saml_response.txt b/social/tests/backends/data/saml_response.txt new file mode 100644 index 000000000..557bb59e8 --- /dev/null +++ b/social/tests/backends/data/saml_response.txt @@ -0,0 +1 @@ +http://myapp.com/?RelayState=testshib&SAMLResponse=PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz48c2FtbDJwOlJlc3BvbnNlIHhtbG5zOnNhbWwycD0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOnByb3RvY29sIiBEZXN0aW5hdGlvbj0iaHR0cDovL215YXBwLmNvbSIgSUQ9Il8yNTk2NTFlOTY3ZGIwOGZjYTQ4MjdkODI3YWY1M2RkMCIgSW5SZXNwb25zZVRvPSJURVNUX0lEIiBJc3N1ZUluc3RhbnQ9IjIwMTUtMDUtMDlUMDM6NTc6NDMuNzkyWiIgVmVyc2lvbj0iMi4wIj48c2FtbDI6SXNzdWVyIHhtbG5zOnNhbWwyPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6YXNzZXJ0aW9uIiBGb3JtYXQ9InVybjpvYXNpczpuYW1lczp0YzpTQU1MOjIuMDpuYW1laWQtZm9ybWF0OmVudGl0eSI%2BaHR0cHM6Ly9pZHAudGVzdHNoaWIub3JnL2lkcC9zaGliYm9sZXRoPC9zYW1sMjpJc3N1ZXI%2BPHNhbWwycDpTdGF0dXM%2BPHNhbWwycDpTdGF0dXNDb2RlIFZhbHVlPSJ1cm46b2FzaXM6bmFtZXM6dGM6U0FNTDoyLjA6c3RhdHVzOlN1Y2Nlc3MiLz48L3NhbWwycDpTdGF0dXM%2BPHNhbWwyOkVuY3J5cHRlZEFzc2VydGlvbiB4bWxuczpzYW1sMj0idXJuOm9hc2lzOm5hbWVzOnRjOlNBTUw6Mi4wOmFzc2VydGlvbiI%2BPHhlbmM6RW5jcnlwdGVkRGF0YSB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiIElkPSJfMGM0NzYzNzIyOWFkNmEzMTY1OGU0MDc2ZDNlYzBmNmQiIFR5cGU9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI0VsZW1lbnQiPjx4ZW5jOkVuY3J5cHRpb25NZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyNhZXMxMjgtY2JjIiB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiLz48ZHM6S2V5SW5mbyB4bWxuczpkcz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC8wOS94bWxkc2lnIyI%2BPHhlbmM6RW5jcnlwdGVkS2V5IElkPSJfYjZmNmU2YWZjMzYyNGI3NmM1N2JmOWZhODA5YzAzNmMiIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6RW5jcnlwdGlvbk1ldGhvZCBBbGdvcml0aG09Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jI3JzYS1vYWVwLW1nZjFwIiB4bWxuczp4ZW5jPSJodHRwOi8vd3d3LnczLm9yZy8yMDAxLzA0L3htbGVuYyMiPjxkczpEaWdlc3RNZXRob2QgQWxnb3JpdGhtPSJodHRwOi8vd3d3LnczLm9yZy8yMDAwLzA5L3htbGRzaWcjc2hhMSIgeG1sbnM6ZHM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvMDkveG1sZHNpZyMiLz48L3hlbmM6RW5jcnlwdGlvbk1ldGhvZD48ZHM6S2V5SW5mbz48ZHM6WDUwOURhdGE%2BPGRzOlg1MDlDZXJ0aWZpY2F0ZT5NSUlDc0RDQ0FobWdBd0lCQWdJSkFPN0J3ZGpEWmNVV01BMEdDU3FHU0liM0RRRUJCUVVBTUVVeEN6QUpCZ05WQkFZVEFrTkJNUmt3CkZ3WURWUVFJRXhCQ2NtbDBhWE5vSUVOdmJIVnRZbWxoTVJzd0dRWURWUVFLRXhKd2VYUm9iMjR0YzI5amFXRnNMV0YxZEdnd0hoY04KTVRVd05UQTRNRGMxT0RRMldoY05NalV3TlRBM01EYzFPRFEyV2pCRk1Rc3dDUVlEVlFRR0V3SkRRVEVaTUJjR0ExVUVDQk1RUW5KcApkR2x6YUNCRGIyeDFiV0pwWVRFYk1Ca0dBMVVFQ2hNU2NIbDBhRzl1TFhOdlkybGhiQzFoZFhSb01JR2ZNQTBHQ1NxR1NJYjNEUUVCCkFRVUFBNEdOQURDQmlRS0JnUUNxM2cxQ2wrM3VSNXZDbk40SGJnalRnK20zbkhodGVFTXliKyt5Y1pZcmUyYnhVZnNzaEVSNngzM2wKMjN0SGNrUll3bTdNZEJicnAzTHJWb2lPQ2RQYmxUbWwxSWhFUFRDd0tNaEJLdnZXcVR2Z2ZjU1NuUnpBV2tMbFFZU3VzYXl5Wks0bgo5cWNZa1Y1TUZuaTFyYmp4K01yNWFPRW1iNXUzM2FtTUtMd1NUd0lEQVFBQm80R25NSUdrTUIwR0ExVWREZ1FXQkJSUmlCUjZ6UzY2CmZLVm9rcDB5SkhiZ3YzUlltakIxQmdOVkhTTUViakJzZ0JSUmlCUjZ6UzY2ZktWb2twMHlKSGJndjNSWW1xRkpwRWN3UlRFTE1Ba0cKQTFVRUJoTUNRMEV4R1RBWEJnTlZCQWdURUVKeWFYUnBjMmdnUTI5c2RXMWlhV0V4R3pBWkJnTlZCQW9URW5CNWRHaHZiaTF6YjJOcApZV3d0WVhWMGFJSUpBTzdCd2RqRFpjVVdNQXdHQTFVZEV3UUZNQU1CQWY4d0RRWUpLb1pJaHZjTkFRRUZCUUFEZ1lFQUp3c01VM1lTCmF5YlZqdUo4VVMwZlVobFBPbE00MFFGQ0dMNHZCM1RFYmIyNE1xOEhyalV3clUwSkZQR2xzOWEyT1l6TjJCM2UzNU5vck11eHMrZ3IKR3RyMnlQNkx2dVgrblY2QTkzd2I0b29HSG9HZkM3VkxseXhTU25zOTM3U1M1UjFwelE0Z1d6Wm1hMktHV0tJQ1dwaDV6UTBBUlZoTAo2Mzk2N21HTG1vST08L2RzOlg1MDlDZXJ0aWZpY2F0ZT48L2RzOlg1MDlEYXRhPjwvZHM6S2V5SW5mbz48eGVuYzpDaXBoZXJEYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6Q2lwaGVyVmFsdWU%2BTElQdkVNVUVGeXhrVHowQ2N4QVA5TjV4Y3NYT2V4aVV4cXBvR2VIeVFMV0R5RVBBUDVnZ1daL3NLZ1ViL2xWSk92bCtuQXhSdVhXUlc5dGxSWWx3R2orRVhIOWhIbmdEY1BWMDNqSUJMQnFJbElBL1RmMGw4cVliOHFKRy9ZM0RTS2RQNkwvUURtYXBtTXpFM29YOEJxMW5Ea3YrUWh4cmQwMGVGK2ZMYVQ0PTwveGVuYzpDaXBoZXJWYWx1ZT48L3hlbmM6Q2lwaGVyRGF0YT48L3hlbmM6RW5jcnlwdGVkS2V5PjwvZHM6S2V5SW5mbz48eGVuYzpDaXBoZXJEYXRhIHhtbG5zOnhlbmM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDEvMDQveG1sZW5jIyI%2BPHhlbmM6Q2lwaGVyVmFsdWU%2BRVpUWDhHTkM0My9yWStTUVlBMXRudHlUTTVVNkN2dUNCaktsVEVlekZPRjBZZHhCWUdFQVVjYU8xNVNKOXBMemJ1L1h0WGxzTkVMZTdKdEx4RUpwYUxubWFENnIranNWczdLaTBLNHRTMGNBUERDWHV2R1FoMmFOVjVQOGJ3N1JWUGhLOGQwYlJ1RklGR09FOHMwTTZYOUpxWDN4S0MvL1lSbVVoeDlybnU3ZWlwMGh5ZitPaUZiVGR2SDY2NTB2LzQ3aVdKcDNZeFlUV0QyMHBNbVRJMUpwWUEwYjByWVFQRkR0RU93d0JxYktxanRJc3ZYVFJzeXJhQkxvbnFOeHN5dHpEWHEra0JsMXp3WGUvSE5QcUVQblczdnNxaFhZcDVGM3dkWThkKzNCOTRMZlpOdUd4a0p3VDNzdVR0OGY5VHRBSlI4VytBUmtzT2M4eDBVaVNsVG5BNHFHOTBLMTR5dkVoVHcvd2drZjFXV01RT3dpZDNpakFYbUV4MU5MbVZvYUxYb3p4VExkTjN6YnJ6VEJIRXc3R2J3ZEdrdU5pMlhZOW16YUgwaWtGRm51VUxjMHUwc0pycEdGdzlaK0VlUk44RzNVUVZ5MjhtS2g3ZFBwWU5KbzhyajIxZFFaK2JaeUtTUHZablU3REkyakdJRE5US1g2ZkVyVWFINGlOTzN4cUU2Vk90L2d4T3BMNE5VNUhLV0Q0bG93VzcwdUJjVEVQRmhwaThpYUovdTB6YzUvTEhvdVBjMzByc1RLZFc5cmJLL2NWaHNQUHErZzA5WHZpZ0QweTJvN2tOc1pVL25tRXFiSzBKOTBrazhCR3I5cXRSczY4bUJnSURtUHVwUkhwWjM4eXNnU2VZN3V0VlVaSG5tQ0dzTzZ2NDJ6OTVOK05Pb3RCTEVZbFd1ZEdzYnowQWc4VkRDSlY5ak95QW95MDZyL1AyUHBsOFhjdmJza2d2T1BMMWdDNnVYbVJJS1lmOEw4UDJCNXVjN0haK0dtUHNOWXRLS2VKRDFFUHovdCt2NlBIbXNVb3dsSDhSd3FMRHdtMUF4dlNLQTR3UXBlQ0dQd3A5YXRYS0lWMS84NUZzRWMzajVzNjd6VlRybThrVEpydXV2MDZEdFVRZDNMOFdwTkV4cWhQait6RUp6U3RxSG04ckhNMVhNQUVxdVozc0xycTVqLzFSNlpqS0dOdFJCbjhwOE5ERGtrWm0vWTV5TXlJNXJJS3U5bnA3bXdaaEVpeWVHeHdxblV3VVMvUzVDRjNnMHVidnd4eVVnalVvd1ZvTkNqYktBbkdtT2VCSW5abkh0eGdIVUhVOUVlTFdyd2pRc3JtUmpJV0R2RkZQa3l6SzJDL20yaitubmNxc2E1OGRLVXZxcGR1VTRJYnNPQng3UGpXdXRBNmY5bXd6YWxyRU1NK0lGR3VPdk9HMC93eUdzQjZLREV6bldjUC83NkQ4angzaHZFSlAzN3REbFgreGM4Qno5TXdKdkd6VG4xbTdCb2xoR0lzSXlCTys1ZXpXa3RDWVVIUURGVE9wbXA0MDlOWHp6ZUNTUGY1U2NDWG5YYjRPd01ULy9VM1JFUnRRbGMrNmU2WG1JRjhoRkJVc0taUUJsS2ppSDkwZHlzYWlsNmN2V3UyQW55Q3QxbWxXcHFLc0MzU2RTRVZDTG1qRjlUQUFUMEtFSGdZQjg3RjZtZUpTTysvOXkyZkRuYVVvUUlUVzdubnVuSCtkT3dWSGZMU0wyL2N5YTltNlQzR29TSVNMbGJPMVRzalhKclVkZW55OTcvM2tkNmhFQlphdGY1U3NETFQ3SjNsQUVJNDROeXJ0NkIxQWdod2JNdkpqd1JNTXRNdUJLc3ltUytKVzc4UFNEWXQ4MG9waDJQTTc1N0tBNCtUMTAvYnZaQkE5Vk1OdVpqNVV3NXRWMnFIS3dwS0t6ZVVETUFiQlBRaGpYcXlQZzFKa09rd2RQMUpnOHRITjJTelBZQTlmT1htV0pBZGJDS2tMb0F4ZTV6cDZBUzYzS3FXMmFmSUt6SHJ3RTJmS1VtamppeURvMnNuMkJHbWtBaTRzbnpiVzc2SUQvSVgwd044aDBaQ2VRc29vKzdtb1RCMEJxSnBkS1MycXlsUktoc3BSTC9henVQdmxaK1pwckJxdXpJdEZkNFVLMkpzQkp6VXcwZkpxcTV1bk9PZENzVWM3SUU3QTNmZ1NmZ3NBd1R3WFZJMEVoME5ySWZpMkFKV1Z2VFpEMys2eFZ3dS96WWhuVjc0VXkvMFE4Mi8yQWtpSGpFRjNJVGNLWHdTNTB6bWtLakxjZDJqa2h5TUFYMWRoQ0wwZElFMUJoN0RNamVvNC9YbjBqSlpPL3Rrbi9xZmYzc3RNb1BYVG9KTnBIU1RjR2ZheGtaMzJYNCt3Q0xPc0VBRWxlMVZSY0kwUkZyOFhHTSsxWU9BTjBodFdGcFMxaG9kSi9OczJqL1FnUVNEemNpQ1FZeUFDd3lFRWZDZjZybnR0VmJyTlJQZWlmSHhBM3B2UnZ5ZGRhNDE5cXl0ZXI0akJ3cmw3ZUpuVnJ2VEprR2VhU2FRbDdXWk5SQXBscXRnNnZPYmpiMHZDRWlFaFhKbmNzQUhxcXp5QTRGeWFUVGQ2R0FySU9adUNxRWVoWk51T01lOVlrMVpya0VkR3pIalJESWk3Q1BKQk12NEZ4ZHI3bnJvN0I1WEhKb0ZMNE1DSUtOWWU2aWZiTUtYOU5uN1FWdnphUmY2UXlaSW1BWENQZndvU1BkN2x6NXl3UDJLSUIyaGhFMWt5eVZ5YVc5T0praWpUY3dvUnZrSXhIU0RqMXFqeGxueXh0QzhVZ1pNWmlwcGgzQXJpcjRiekIzUDhIbGIzejZ0OW51KzZMemNiN2ZObVo0UHluaU50Vk9OQ0lHbEh4dTBSY3hQK3cwUXNsM1BtTzJLaHBpc2RIanhvSUJ1YVY1NXdoTlFFNmdNNFBrT0xINDc4Rzg4bUxkd2s2RFpkWVl4L2d6RWE3b3ZIL0pReFp2TzRLdFVTNmZjZHJxV2thTFg1cEhkNkdneFBGZ2NFc2Nad1ZqM2hCS0xFQmE5L0dodERINEhzRnNRbmpPZnNDQkNzN0tjRitmTi9oSUdUeHFqTVlKVHJRYmNtdWF5dk9xR3RQMDFPcXltR24rVm5FSVkzKytQcm95SFN3K0Q0b0JIVG1maFNXRmJLZCtuTlVFS3BhRVIxNkdCU256WktQRVRVSmdRWEw5QWJRQ3RXVjFHb0UzRWNnMDZYaVd2aHFHakpGNldtdEU4dHY4Q25rZmxMNm91TDRvNldpbmx2WnNEdkZrS0R6TDkwUTNsWC9NanBtRTFpWU9uYzdISXdEVGwraFRRcHdsYXJiTDVUNGNkZTg1akNwYU0xU3p1TStiQU5zMHlXVDA0ZXJUVFc2cnhlbXFDTHAra202TVVMTlZOcE1CazBiQjJpRU82UlRtc3VpRlhDUU1xdU5xZjdkWXUwTFFCZzQ0MkJzU1pBV1ZrWEVZblduOURLdTRSby8veEFsb2h5VHozWlZmSkhuWVBSdDloSUErRHVUL3c4T2ZzTURIWnlCelUvL0JEa1NiNkxjMHdraVA3QlhIdjBoNVdud2dNWUxlZDBPalR5UWI2aGxpVnQ5b0FjaDRFVy9EZUlBdkpaQ1BYVm1pUFFYTGVsOVJIRko2bXFiYVo0TCtaZG1ONmQwcFZNZ1FveXhmQTR3dEwwYVpiNnFZYkhibjJMd2VBQVZwL3M2TzVlMVExdnZpZDRTWHo0a2l3RW1LSStIeXZEQ1pnekpQQVN5Z1gvWDJFWEZ0NGV3SjVmUFQyVXZmWnhQWlpqMFZGSFpyUFQwWVd2VE16bjUva3hoT09oM2drVGdDSmNwNWVsZnp4cEFPNFl1a0NoNHJXdVNndDRqVUJyaWNYbFdWdWo5U3JSZVhUalNHTktLK202NWovUDllNHRHT0RkMk9BbjNKTVQ3Q3FuaDhreTZpZjVjbmpVMmU3UDhTZnBONGwxWEFiZEZEcGk5bVJYamEyTzR1RWFHNGNvNW4xcWNDT3ZNMWYyblFBY1ZGNUFoSXhueS96TWhmU2l2RXdOQ0Zyd2tBWDRyQVE0WldUNldFakFyUG5jb1Y4Z1VRclhxQVA4NDJmK1lNWWI5RHFncmFicEg1a3ZuMnQzcWRldGJHODJ0QWlTamhPcUxNYW9iU2F4cXdWa1lUOHRTMW9rUUt2MWZoZ2t6elpEOE5IQnVQQzdNVHdXS0VCS2tDRUUzRWRFMXhNQURLd1B1M3NSaGpSaExXZyszZ2srejJtdlU4cTBhTlc0Y3hObUdoekx4eEY0Q3NFNStMQ1cwOWFpUVJOM1VvWmg1aktBZzBiMlh3WHBLS3pycUVTY1BYdnI0L1dWUTMyMm5qRWRvQVdXR0t2WnBKMlRlREo0eDdiT21LVElFc2RHWU1UZzFVaEU2eFFQcnhqS3dWeGFJNVJyaVE4a0xpaGgwa0t0WHQvYTVsSDhzUjVwR0ZISGZ3dlNVb3liQTB1eUVDNnNRVitPbTVReUZmRmpqZHFCOGNpOGxQS1hLTHFCTHJ6bjNmUkh3TmQwbzFiRTg0aGllTkx5UlhZVmhrRCtFNEpGaVd3ZWt3U3VWM3BjQk9ybnRVU3RoWmx6M3hIUURUVGNJNWliOFJyQ2swZEZ6YTgvQmw3VUdtWlUwSXZ2UmdvVXF2TXNHT2dMY3pGWmRpZnJ5aGNiUTY4a2ZzZ3lCMHppdC9MN1BSV3V4RkdYdDFoTVZSVUZ3WXBJS04zVkI3cXVKZlgwamZsU1JaRndMaXdlK3VhYndmTVZ6c2doajUvOXZNNzcwK0JaMGtJcE45NzBTMG5BbHl6R0h0aW1nTUl1RXFhbUt5QTNTQlI1aHZIYmRyNENnTHFUbXIzbFFnWmpnSkNvN1FXYUJWTXdCR0RpdzVOVVhUUnBycWc4U3h2eDlnNWZwbXMrL0o2QjFEelNTM3ZRZzgxdHFRU1ZDWVJpc0Y3M2VqZlFuZk4zcUszd3RJRDkxQnRISmFvMEFaUUdKVFpKOXVsZ0kzV3hzdWR4ejB0VHVpNlJlSWpmSWsxekZRdFpwRExGMnB3NGpTQVdQTlJqNDBYdVIrRzFUVlI3OVFiME9FYkw4RDFoTU5zWmo3MTZNbUhSOTlKaUxNdm1FWHV5a1V4VGhGYjRMTzZVbW1kU3UwTlBpMXQ2NmNkYURpQWhMaVBFTGdUNkZsenA2T2FGSGNSNjRncEtyemtTNDJONEhJeFpNa2R6M0FsYkRhK2pOWHZPR1l3UWl5K0xNNENZWGtrTWtHR3ZTWis5R2xWQ0l5RXBJaXIzbEQ3bmdzZGk4emxGWDYvekNaczlQSUtwZFZlSGJGZi9GS20wV3AreHI0Ykd0R0RrVHR2Nk1Manh2YU8zanFHaUFWeERKVWFkTVBlS2VHSm5uempTdnpKbGdOVHV3c3grRnF5L2dPMkwxMGowWmhDWi92dE9NelVjNjl3cGhKZm9FNzU3V3lOeFJOcThJc0Y1Tkg5Y0x0b3UvbUNxOTc3YnZPSkRrSURCN3lKWEJ6YUhVQkJuSXJra1Qyemg3bGJmUm5SREJUSFZraVZMazVESUxqeC9XL1BSZEZpUUM2SzRmZGx4Y29JbzlMcnM4ZFVWZkt2TTNNYnJ6c1hGT3ZtVVh0K3NsZldvd3UyTC9ndG9mRFhvTUJZZnlEcWIvWlRaRWZ0MC83blliRm1relBEUlZacU5SR0F3YWZVNTU1UjB2SWtNbGR2VjdKUzhNT1BNYWlXQVBpelNLRG4yRzNvcys1MzRFQytaOGZnWmFPVWpZL0xLME9vME9RMmhvNUV6MGNMYWpwUjFINk9FNEhvUm1ydjQzZkFjdGpYc0hYdi81RXg3emdrWk1NZXZhTFNEdjZtcjFGcDk4QXR4L296VTFGVDBoMDUxcVcwR0g2VWpRRXk5aExSZDBBMnFkUTRMZXpReDNvbDFTblhsamt2MG4zTXFlaFozOC94bzZhdHFDdkJtQkc3amlUdXd6YnlVUngzRm1TM0NCNllOYnFON3hPYVRZRnlkOEZDL01nY0xGQmMwS3F4MXllQ2VUd1hucldQb0dvdllVQlYxYjA1cWtIa1d5V0RUaCsveXJFNzF0RjNxbUQvd3F6cUJyNE04NERtWWVuQkdFOWxtb3FIZEMyWnRpK09KVFZKcmlHZWxQQ3RjZnZRaUlQcHdDZ3BFNmg1ekZhRndLajRuZGtBUkRpTC95L1EwWTZxNU5rM1g5RURlTmdjY1pIcFdmOUpKQ3M2a29wdXRtYjdDczIrbVJYdER1S09DaGY5UVUyN3Bmb1NJaklYK3NGdHY1c0hhSms2aHBZMlpzUUhzaTBYbFowc3FMTnQ5ayszdTVnYnBSU1JCczlHaC9BaVY0dkNyYTRkOTh5U0dCdzRSR1FhSStpQ29RaG9YK3lxc3VrYkx6bXJUU3FXMVRXaXJReUlHZ1Q5VnFERE1mUzAxeGdQSlNFSTlIWlp6TGlFVXVGMm1CMi81Y2dqaEFUaWQrdGV1UVB4aldhN2NSc2t5YUhuTENjQURVUU9ESUFPVjJDWXROcnAwY29ZL091S3ZzaXlJT0lacVJ5dE1PMGVNZ1ZJWTBzWmdxeVEycXlubUx0NDBmWmd3SFVyV245Zm9TYTNtMkVRTy9uOS8yU2NuelJWdVZpVnNjM0tCSElQL3AzNlJlSWowTGlNcCtPQ0p3SHlLVW1UeDRBU1V0dXVhWktlRHl1QjlxcXJuUEFNWUVCeElsTGFvdXMzV1pHakIrcW9ub3QvNmk1UE40bUZjbHFDcUxhMGJHbks4ZnJxYy9yd2tuVGV0YUE0c2tXTEw1L21qNEd5MitFQkh3a0x3UXd2K0FKdmZTOXYvNDl1LzY0N1ZFYW15UzdZQ2ZEUHNBQUREQ1FFcWJNQ1h2Ui8xVmEwWi9YUWhoNlkrZUt0MEVpRDdpNmRZODJtQkFoNEJMRmRVV3VGZHVrdUVwaGZ2WXB3N2loVjNxTjB1NFM1NTRXU0dUa0ZsdlpYNG1hbkF4a1g2ekQxS0NWaEFMdEJnSDgzdkhxam9uc0lwOFMydHgwZ0tiYzEreHVaRVppVWlNVVlVdTByQVFsRFcrZHJoN3lVRHZqekFHSnBmTk01eThaMW45em93VzZ5YW5VZWFBNjhSZDd5TUxobFd0NVh6bGhBTVZDZmZYZ0pFelR1YzJEbENVOXNMLzVTVkRaV2N4R1E5aFM1cnJtK2VyQ1Jxd2FJQk1DNUtza0RCZHdOWmh2Q0FCdEpqS2Vla1FUSjd5MFp4SGNhbGVCaU1rbkYwZVRDZzFvUEhPUVZLQ3V3NE94cHRZUS9xS1V0TEFIWFZ2OTlLMGRWcWZDMmpVQWlHQmVYa0t3aGRYTGtJYlZxU0EyZmxraXBBeEhYNnByUEExQjF3eTVab3hPUFg4RVExOW92eXpBbFg1dHU0OXEwWC9PSExFN1o5T1cxenltRXR6ZFpyNXJZbWtFcVdtcHVSNU5jeHFwTWlZam93dUNXZWhubzIyeG5JM09IQ0xDZkFKaHRrcklhL1hPc0tZRFpCRzFJMGJsN2taR2R5cEtUQlhYdXl6WE5WUlU5L005ejhaVytwdG1oZ2NOUzBJS2VaaSs5bFl4cWRlS3lnbldTTTV3czdSYUpmNlRRZTNSaWJZUjFvNkhwRzB2VHpiTEtQZTZnRjJGODdiWlBJei9mcTNLWnZiM3UrSnhZcCtJVjBtQi9VN29YelhRRk1RK3VmWllpNzUxbkx6WlVxRE1ybU53TFJPVUFNUk8rVnJtblkwSVB1cFBVMXc0b0hBb1dnVGRnTk5pNk1uTFQ4V0pmUlhjT0pKMk1lbUc2K2ZNeHNZUU52UVJwa1RGY05vaFV6Y3ZjcHJ3NUV3WEVZQTJzbzczL2MvY3RIRGcreU05YlF4REppUlltRnFydkhYb29hS1JyekxnUjZLVWdoM3ltaWxaQ0lSSm9KbTE3aEtHM1pxTTE0Lzl5OUc5OE9BZjNkVTlqMDk3aUNlaEc3a2VxYXRJQ2hFWmJqbmQ4Y00rS3djN2FtVWp2ekQzQmNvMHl3MDJxT054OWF3OGhSblZiWDZhdkRJbGhySHZ6SU44MzFvUjljRHBwMG1DUEJXZFVDQlNqVGJ1RkZqRC90WElSbGxlT2JraFFKSUdSNlE2U1MxcXkzT29WT1VheFl6THY0U2s3dndrQUMwUitGREVIeVFZbFVhbVVkTWcyUmdwRUdhSVd1V3IxaGNnRm10QmREV2g3ZFBuWTF0U3VKOC95MXp4NkRvN2ZJYmNFenBBK2E0ODNtRG5vemdld3VmaFdqVCsvUS85WlEreFQ5UWJBT1pQSXhHV3VhSXVrVk8zSWxvZDhJM1NGZFJCTHY5ZXBDNzFLeXpSdVlpMktkOHJ5NVNINit1WnMxUHlZUlpRakdDK3Q4VzRtSE82Z1lFRWVXSkJ1UWhnSHdmV2xhZXlWb3hac0NBQVZKRUllT3hPZDZtNW45OHRCUDdHTmgxT1M0eDRCS2FVN1A0UVQzNVVIZW5meE84WWFQUThmbXlobUJhSVJVZklBTVN2ZTJZRFp5SWNNTTkrN0tNSVVabzJ0eXRvYzdCOGVvZzBNaUkrVkpFdFg0c29FRjFSWkhQZVV3NWlCTjI4OTh2MmVTcGNnVUJhWHFzOUN5VlZtTVJQMEtLUDJ1REt4MUdJcUhjS0ZCOXVQVWRkQS9vT3dNa0tVUWsraFZVVDVPbEVMdjd1a0FBUEE0eE4rZkczVmYxeUVKV0FiVGx5dWtGcThjNXBTRkY1cXVHbUgwVmVpQzVvVEFka1VES3Z6WGhWWUs5c3BRYjNVZ1Z0Qld6N1ZScnlOUVVST3BIZU5xeDlhZHA4YWREWCtRSHJUKytYblN4VVI3SVdGanlNTkZJRWlMWmkxdks1UVVrZlRDUU9qdjh2SHdiUi9MRHF3Z3M5bXdsT3pPY0RLdVBVK0dTb2lnVFdRejRWN0N2SHRaVDI3WUdKVG44RFFFM3IzdjB4aWxvODJ2U3VXSDg0WEU3VEJsTUpFb2R5eDNDRngwVUVkc3VhRHBPSEV3UjZYNlUyU0xseERYSXVZeEhlNXh2NjI4bXU0bDRMSnBYUjhkYmljTEZKQW55Q0FVeDJLb2dDamt1cmU4bXNUZktDbG8wamFlN1hNR05PSk15b0ZYbVlHZUh2eGhNUGMzTEtYLy9VY1p0c3p3dFJrQmNFdURXQysvQWNWZVBOSHVOWWI5MEpIcnRucGg1ZDlhL1lpTkpzY1N3QTFwUVZrdW1TQWtPQWdLdWRzcnl3c0N3Zkg1anNydVpHUTJDd1hKRXQzUU4wU2NLUlVnT1NCQ3FYa1BqZDVSVzJuOFZpamt4anovbWptakhCNmk0eHM5NEU2Nzk5STAyaldYNVd3UDZhTFRaTGt5TjhxNDUxT0RmeUZVZEY5WWsyZXQ5VUpsV1NzRFJMSWVCd0ZyQkEyZTdyRWsybWFLVUNCRW5PUWM2bUhVMXQvZ3gzK1VXVVFXbkpMZVUxbWUvbkFEdy96UGUwd3d0Vm9BaERZdDBoR1hQblJydjFoUHRGS01CeWtqckg3a0J5U0R3WDlQMi9XZkNkQlE5K1J4cHRsR2hvRmdpMUs0NVlOeEpEd05wTmd5MDV2WXUzVUtrMkpRYVNGUzcwK0Y1NzluRE5RenZpK0pPRlRsdDFmWDJGNXk5NEV2NHZobWRQSmRVOFVVRjU2Ymx0emxKREVFdmsySlFrOTM0aHpwTXJGZ1d3ZHUxUkxxSEhCN2h2T2hnaHNqV0ZGY01zNjZaRUtWcVhKUytxWWNVMHk0akwySVQrNlF2N2pvQ3BWbUdzUWtGY1FyblhxOUJiOTdaUS96UCtwaldmWTU0UmNRVlMydUU1YURObVVyVkdLK3E0d0xRcUhuRVViT2puSHFFeGlacUtxOVdRaUtUK2c3QS96bVlIQ2k0YzFTejRNVWhHb0t6U2l4aXoxYUNJUEJXdy9vczR2cUVqbXgzOGx6YnV0OWNWbElzeGNkTUpUTERRK3ZOZ0YyY1ZRaVcxRTQ0d3lWcnI3TUFaOE9KRVpFSzlEZWt5MzJQUkFuSkRUVXVqdGFscmJ0T2VOczhyS09uTjcvNFRqUEwvZmRlbEI4bjA4WXdSNXdmbU42VGpGWUhRSDFjbUZmK1AvNUxVMTI4Q1pEYjNQUStxMlFJazV3aE40eGwvcy9lb29pallmeWtDcm5aSEhHWkluTGhoU2pWbk5ISWdTL203VWV0NlhBTDdvZUl5UFRLeHVnbDJzRWtUQzNnZ0tjTnFZR0E5U3ZlYVlaQ00vWHNQRUtQbWs3QmlRNmprWFBKaE1yREd4Vkc0SW9aSDgrYjBrUWJYR2l0Mkw0L3hZdHh1bTVzcFNPSjdsTDltVFpRNnBxM2JOaTEwZU1mZ0ZWaDc3NU5JRlc0SEp3U1FtaTU0bk11blZTQjhxdjZKc0w3SGlsZ2N0ZHFSNThTTjVad1lCa2dOR1hzYjA1QXJWemVXbHh1Y21BSHNPT3dyczFnMzh6bTRZN2ZPZmducmFhV1kxanZZOFlEODZQZThkZzR4cE5paTg3UnNDZk5WK2NKVmMraktFdnpuZVY1Zzd0RmlxZCtsZHp4STlKemdSS2t0WUV6RUpRSVU5M2UvclJaN1lrVkZtNVV1cjVhMWYzcG83T0VtYkJUc2MrQ1FaOGNnYmIvbUphRXJoa3NyL3JURjBNcjNxeDl5SlJWSEJ6YWNWd0dScEFRaURPdnJkWU4xQXBVOTRyR1lrVFVzdWs1YjE1Wll2QVZxRlRzVlVMaS9HY29mbEljMm01Z2RFTFZOblRmdXY1Zlk5S1NlWHFoUU80S0pOYVZmbHAwQ0VKYWFFZFNLUXJJNXRaT2w1RkE4VXZlNmxTWVd5TVk0REl4a1RiT1JoWHVBdzR6b1RTMjgrN3d2TXhydVBkZnlKbUJCTkhQdCtEYmdKNHovcHJZWUhpTmFMTXNZamtQZE44ajNKZDczQXJFZk92Um52MzYxSVVVMFg1RDc1dlRSdlpkbzMzWERzanRlOU4weUo3K2lIQnF1a1FJY2pIVW9ic2RQN0hOajBVYWNSMHIvTmRlVTlGNFBNc1VLY2t6Tk4rZGhyMVI2d1J2R1VZb1pDRWJaWlJMWEt4QnA3SElUNEVQUktHakIvdW1xTFhhMXl6RWx2QW1WQUJhMDFZN3dGdk4wM2Ywb25FbUhTM2w1d1paRmV6cjVibnN5T01XVGxhMU5kaW1ZNXNVeE15VFliZmc4dzB2cXNEc28zWFAxYndLdzZ3M3VIRGQ1UHBSWnVDSnR0eWk0ZzJGeWI0Ymg1UU42ZkdORTI2ekRGN1Y4QmJwZXJLNkFKQ0xTWm5kaDZMMTlPUTBram4xUGpEMGk4c1BZcGFXOWxVeVJkZElPKzRWQS9LemxPUzJ4M2s5VUtUdElsTTBUSVdtZXFIS0dYUVpocGpvVGI2VlNKN203cjZaaVlQMnVsQVVvZmVWL0o2eCtzckxEQXkyQ2ZFNnFrREZ1OU9NWDBBSXVnN3loQUtOMDRyT3hVNk5tcGtjOUZ4bXUvVS9vR3hHdmIzeFVFTDYwdE1sSE9EaWtqY1I5RDJrKzRwbEc1WnV0d0FIY2kwRU02WHRrVEhQOU5QMlRTR1VFN1E5SGYvU0VEc2V0a25hZXhvWmhDczJLWDFMeU5JS0U0N2pkMkR3MTUreDRRVXV0VUFTbzU5Q1lHMVFBeW9BVVhrV3dtbXkzTGdTUWp5T3ZLV25qaE8veWpPd0FyWGd0NFBrSVVnZDQ1N05ReFpMbU41K0J4NVJoQ0FHdkUxYmxOZjlMek9keGJiaG5VZ2Z1RDM5MXVSRkhjS2RYREY3ZmVqb3gveThtaWZJcTRWVzQyajBHQnFOQUtkK0prMnJCMW9hOTRiT2hxcVVzanhqWnlRaGRXTzhNblR6T2tOaGVpZXU2blYxcW5yZ3JHU2huWTNJMlczb29GNFNnczRjZ3drZ2h2dHpFa0xUbU5OUm83RTdudVRuMkxJcmlGSnlvTmZQdUp0aWN0S0JtNzRGZytkWVBTMlIzTzNmOWxBZWxiVWZjbzZGNU9EL3hkS1VuRTh0V3FOMExVcDlWQUptWVZYZFVDaGJ4MjM4MWtDaStLNDJoRzUydFNQYU1hb1dTb0xQY2Zrb24rc1pYdjdEdEtwZi9HTzdhcUMza1pzRGpva29haHJGZGJWSlNTZWhrNGp5K3RzRHplQnJKSjBrMVZrUnJHN1NoVHZjTmd1cjVucVRUTEE5dlJMQmJNTTlhNlI1NEZ0Z1pQOWFKMU1aMEdCcUVpMnF6Ui8yd2tYQlhwcFhZdi9TcU1RV1dhbTVsSHBMVktxaDN4ZHRjNFdmck9mYldsbU1PNXA5Z0JUSFp1YUcxVGFkZXFRVVpKQmZBS01ENFdSR0NsMDFaeDRTVzE0YzZrdnFKdXExL080N215L3RsVHlLWndpYlBkQTNRMVVGd0I3R2Z4anEwaDN2ckxFbUNrS3Vsc0VBUkN6UnZNVjJSVnBVbFpUV240Y1Boc0hjcTNROElHSUYyKy9nOENFSU4vMU8xcVMvMkpXcXlDNmtIb0w4Y2R2R0VHbmkxSTNDTk1JcXhxaHhJL1V0R3REc2VwYmwrSHI0elh4MzZna3BCbXBoT2xkTFVYTHAzVEtibVVZRWJSWHcvZmRmeFQ3WDdZUFhHQ0hHVG1uTzk4WkxDOTA2Zmkvekd2b04rNlpzbCs3MkpWMGxJWEo0V3dZdWxFUmZHbkFDWGNoa0Yzei9ITWR3elcwTUFFaXptQmwvREo2ZUoyU01PSG1Uc25YbElGRDRlcFRrYnFBQ0dpZ2I1UExFdHdQRVRjYkNRckM5YUtTU1FnSTdEZXd1aWlxM2J0Y0RUWkIzeEI5WWxlbmhpU0FXNjIwcmwzc2ZjY3d3eGFSOHBDV2Rzd0x3dmFxcDhjM01PV3RCc2xPcmVTSkNEcWgvdzBYbm1WMFJVWFpNM2JvUmkwVXhsaHVUeDFlM1NTd09pbTlOczNYV3NoTmI4Lzc3VkhnUWhRVFlSUU1NRllYaWRmMElCKzBtSUpocWNoQTlUeUY3dGRjSDhrUUJUSHNEWS96bFpqK3EwNlFMd0JkbTkxc3IyK3VzZmxlaXB3WUMrcmdiNHROVnA3VU5rYkVqTnR6ZWZsTi9VRTlkbHZtT2x6V1dtZkh2NGVkUGkzMmJmeUNRS1d6SGJVVEV3NU0yVFpsZnpNaTFWUjVsaDBxQ1lqaDNITUlmL2MwcHBKd2I1b1lFTnBBenlxbnlmdmlTV3lBYzc2L1l1VWwvb2FVaysrYzBZc2d1TGo5ZGFQdVVvemhoZ3VjSytQRGlNckI0ODU1Mk83VWg0aHRwNmZ3S2dJa1JCTVFIUTd6MmV5WXovV1AwQm9ZZVhjOGc3aUprclhFNzA1bFo1bXhGU0poT3E1WlNleVJSb21pUm41K3VRemM5ZFdWQjBYb2JURXdOc0VRM2FIZ25JY29BczY2UGplUT09PC94ZW5jOkNpcGhlclZhbHVlPjwveGVuYzpDaXBoZXJEYXRhPjwveGVuYzpFbmNyeXB0ZWREYXRhPjwvc2FtbDI6RW5jcnlwdGVkQXNzZXJ0aW9uPjwvc2FtbDJwOlJlc3BvbnNlPg== \ No newline at end of file diff --git a/social/tests/backends/test_saml.py b/social/tests/backends/test_saml.py new file mode 100644 index 000000000..abe256976 --- /dev/null +++ b/social/tests/backends/test_saml.py @@ -0,0 +1,104 @@ +import base64 +import datetime +from httpretty import HTTPretty +import json +from mock import patch +try: + from onelogin.saml2.utils import OneLogin_Saml2_Utils +except ImportError: + pass # Only available for python 2.7 at the moment, so don't worry if this fails +import os.path +import re +import requests +from social.p3 import urlparse +from social.utils import parse_qs, url_add_parameters +from social.tests.models import User +from social.tests.backends.base import BaseBackendTest +import sys +import unittest2 +try: + from urllib.parse import urlencode, urlparse, urlunparse, parse_qs +except ImportError: + from urllib import urlencode + from urlparse import urlparse, urlunparse, parse_qs + +DATA_DIR = os.path.join(os.path.dirname(__file__), 'data') + + +@unittest2.skipUnless( + sys.version_info[:2] == (2, 7), + "python-saml currently depends on 2.7; 3+ support coming soon") +@unittest2.skipIf('__pypy__' in sys.builtin_module_names, "dm.xmlsec not compatible with pypy") +class SAMLTest(BaseBackendTest): + backend_path = 'social.backends.saml.SAMLAuth' + expected_username = 'myself' + + def extra_settings(self): + with open(os.path.join(DATA_DIR, 'saml_config.json'), 'r') as config_file: + config_str = config_file.read() + return json.loads(config_str) + + def setUp(self): + """ Patch the time so that we can replay canned request/response pairs """ + super(SAMLTest, self).setUp() + + @staticmethod + def fixed_time(): + return OneLogin_Saml2_Utils.parse_SAML_to_time("2015-05-09T03:57:22Z") + now_patch = patch.object(OneLogin_Saml2_Utils, 'now', fixed_time) + now_patch.start() + self.addCleanup(now_patch.stop) + + def install_http_intercepts(self, start_url, return_url): + # When we request start_url (https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO...) + # we will eventually get a redirect back, with SAML assertion data in the query string. + # A pre-recorded correct response is kept in this .txt file: + with open(os.path.join(DATA_DIR, 'saml_response.txt'), 'r') as response_file: + response_url = response_file.read() + HTTPretty.register_uri(HTTPretty.GET, start_url, status=301, location=response_url) + HTTPretty.register_uri(HTTPretty.GET, return_url, status=200, body='foobar') + + def do_start(self): + # pretend we've started with a URL like /login/saml/?idp=testshib: + self.strategy.set_request_data({'idp': 'testshib'}, self.backend) + start_url = self.backend.start().url + # Modify the start URL to make the SAML request consistent from test to test: + start_url = self.modify_start_url(start_url) + # If the SAML Identity Provider recognizes the user, we will be redirected back to: + return_url = self.backend.redirect_uri + self.install_http_intercepts(start_url, return_url) + response = requests.get(start_url) + self.assertTrue(response.url.startswith(return_url)) + self.assertEqual(response.text, 'foobar') + query_values = dict((k, v[0]) for k, v in parse_qs(urlparse(response.url).query).items()) + self.assertNotIn(' ', query_values['SAMLResponse']) + self.strategy.set_request_data(query_values, self.backend) + return self.backend.complete() + + def test_metadata_generation(self): + """ Test that we can generate the metadata without error """ + xml, errors = self.backend.generate_metadata_xml() + self.assertEqual(len(errors), 0) + self.assertEqual(xml[0], '<') + + def test_login(self): + """ Test that we can authenticate with a SAML IdP (TestShib) """ + user = self.do_login() + + def modify_start_url(self, start_url): + """ + Given a SAML redirect URL, parse it and change the ID to + a consistent value, so the request is always identical. + """ + # Parse the SAML Request URL to get the XML being sent to TestShib + url_parts = urlparse(start_url) + query = dict((k, v[0]) for (k, v) in parse_qs(url_parts.query).iteritems()) + xml = OneLogin_Saml2_Utils.decode_base64_and_inflate(query['SAMLRequest']) + # Modify the XML: + xml, changed = re.subn(r'ID="[^"]+"', 'ID="TEST_ID"', xml) + self.assertEqual(changed, 1) + # Update the URL to use the modified query string: + query['SAMLRequest'] = OneLogin_Saml2_Utils.deflate_and_base64_encode(xml) + url_parts = list(url_parts) + url_parts[4] = urlencode(query) + return urlunparse(url_parts) diff --git a/social/tests/models.py b/social/tests/models.py index 7dae52f75..80bf6871e 100644 --- a/social/tests/models.py +++ b/social/tests/models.py @@ -117,7 +117,7 @@ def get_social_auth(cls, provider, uid): @classmethod def get_social_auth_for_user(cls, user, provider=None, id=None): - return user.social + return [usa for usa in user.social if provider in (None, usa.provider) and id in (None, usa.id)] @classmethod def create_social_auth(cls, user, uid, provider): diff --git a/social/tests/strategy.py b/social/tests/strategy.py index 9ccd7d04f..d88685f2d 100644 --- a/social/tests/strategy.py +++ b/social/tests/strategy.py @@ -50,6 +50,26 @@ def request_host(self): """Return current host value""" return TEST_HOST + def request_is_secure(self): + """ Is the request using HTTPS? """ + return False + + def request_path(self): + """ path of the current request """ + return '' + + def request_port(self): + """ Port in use for this request """ + return 80 + + def request_get(self): + """ Request GET data """ + return self._request_data.copy() + + def request_post(self): + """ Request POST data """ + return self._request_data.copy() + def session_get(self, name, default=None): """Return session value for given key""" return self._session.get(name, default) From 17def186d4bb7165f9c37037936997ef39ae2f29 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Mon, 11 May 2015 20:38:55 -0700 Subject: [PATCH 3/8] Add python-saml requirement (temporary commit) --- setup.py | 2 +- social/tests/requirements.txt | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/setup.py b/setup.py index 8860b0a0b..a706a77cd 100644 --- a/setup.py +++ b/setup.py @@ -55,7 +55,7 @@ def get_packages(): requirements = f.readlines() with open(tests_requirements_file, 'r') as f: - tests_requirements = f.readlines() + tests_requirements = [line for line in f.readlines() if '@' not in line] setup( name='python-social-auth', diff --git a/social/tests/requirements.txt b/social/tests/requirements.txt index 33cef2575..12a8d0dad 100644 --- a/social/tests/requirements.txt +++ b/social/tests/requirements.txt @@ -6,3 +6,4 @@ rednose>=0.4.1 requests>=1.1.0 PyJWT>=1.0.0,<2.0.0 unittest2==0.5.1 +git+https://github.com/open-craft/python-saml.git@9602b8133056d8c3caa7c3038761147df3d4b257#egg=python-saml From b6b557eed3ed7cfd630d329c165ae7d3fe1355f1 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 20 May 2015 10:41:07 -0700 Subject: [PATCH 4/8] Make IdP name format slightly more flexible --- social/backends/saml.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/social/backends/saml.py b/social/backends/saml.py index 135cf4086..0a50c96ed 100644 --- a/social/backends/saml.py +++ b/social/backends/saml.py @@ -28,7 +28,8 @@ class SAMLIdentityProvider(object): def __init__(self, name, **kwargs): """ Load and parse configuration """ self.name = name - assert self.name.isalnum() # If 'name' contained a colon, it would affect our UID mangling + # name should be a slug and must not contain a colon, which could conflict with uid prefixing: + assert ':' not in self.name and ' ' not in self.name, "IdP 'name' should be a slug (short, no spaces)" self.conf = kwargs def get_user_permanent_id(self, attributes): From de8152360b9f1c644fa3192bd601cf9452cd6a22 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 20 May 2015 23:47:46 -0700 Subject: [PATCH 5/8] Add an integration point for extra security layers like eduPersonEntitlement --- social/backends/saml.py | 24 ++++++++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/social/backends/saml.py b/social/backends/saml.py index 0a50c96ed..703a886e8 100644 --- a/social/backends/saml.py +++ b/social/backends/saml.py @@ -14,6 +14,7 @@ # Helpful constants: OID_COMMON_NAME = "urn:oid:2.5.4.3" OID_EDU_PERSON_PRINCIPAL_NAME = "urn:oid:1.3.6.1.4.1.5923.1.1.1.6" +OID_EDU_PERSON_ENTITLEMENT = "urn:oid:1.3.6.1.4.1.5923.1.1.1.7" OID_GIVEN_NAME = "urn:oid:2.5.4.42" OID_MAIL = "urn:oid:0.9.2342.19200300.100.1.3" OID_SURNAME = "urn:oid:2.5.4.4" @@ -210,11 +211,11 @@ def saml_metadata_view(request): errors = saml_settings.validate_metadata(metadata) return metadata, errors - def _create_saml_auth(self, idp_name): + def _create_saml_auth(self, idp): """ Get an instance of OneLogin_Saml2_Auth """ - config = self.generate_saml_config(idp=self.get_idp(idp_name)) + config = self.generate_saml_config(idp) request_info = { 'https': 'on' if self.strategy.request_is_secure() else 'off', 'http_host': self.strategy.request_host(), @@ -228,7 +229,7 @@ def _create_saml_auth(self, idp_name): def auth_url(self): """ Get the URL to which we must redirect in order to authenticate the user """ idp_name = self.strategy.request_data()['idp'] - auth = self._create_saml_auth(idp_name) + auth = self._create_saml_auth(idp=self.get_idp(idp_name)) # Below, return_to sets the RelayState, which can contain arbitrary data. # We use it to store the specific SAML IdP backend name, since we combine # many backends to a single URL. @@ -257,7 +258,8 @@ def auth_complete(self, *args, **kwargs): everything checks out. """ idp_name = self.strategy.request_data()['RelayState'] - auth = self._create_saml_auth(idp_name) + idp = self.get_idp(idp_name) + auth = self._create_saml_auth(idp) auth.process_response() errors = auth.get_errors() if errors or not auth.is_authenticated(): @@ -267,6 +269,8 @@ def auth_complete(self, *args, **kwargs): attributes = auth.get_attributes() attributes['name_id'] = auth.get_nameid() + self._check_entitlements(idp, attributes) + response = { 'idp_name': idp_name, 'attributes': attributes, @@ -276,3 +280,15 @@ def auth_complete(self, *args, **kwargs): kwargs.update({'response': response, 'backend': self}) return self.strategy.authenticate(*args, **kwargs) + + def _check_entitlements(self, idp, attributes): + """ + Additional verification of a SAML response before authenticating the user. + + Subclasses can override this method if they need custom validation code, + such as requiring the presence of an eduPersonEntitlement. + + raise social.exceptions.AuthForbidden if the user should not be authenticated, + or do nothing to allow the login pipeline to continue. + """ + pass From 6dd502464b1e7e77fd33ca3f4bf8990f0271fdd9 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Wed, 20 May 2015 23:48:07 -0700 Subject: [PATCH 6/8] Minor consistency fix --- social/backends/saml.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/social/backends/saml.py b/social/backends/saml.py index 703a886e8..c6ea5c95c 100644 --- a/social/backends/saml.py +++ b/social/backends/saml.py @@ -40,7 +40,7 @@ def get_user_permanent_id(self, attributes): If you want to use the NameID, it's available via attributes['name_id'] """ - return attributes[self.conf.get('user_permanent_id', OID_USERID)][0] + return attributes[self.conf.get('attr_user_permanent_id', OID_USERID)][0] # Attributes processing: def get_user_details(self, attributes): From 02ab628b8961b969021de87aeb23551da4e751b7 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 21 May 2015 12:08:10 -0700 Subject: [PATCH 7/8] Minor cleanups --- social/backends/saml.py | 13 ++++--------- social/tests/backends/data/saml_config.json | 5 +---- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/social/backends/saml.py b/social/backends/saml.py index c6ea5c95c..0ea11c07a 100644 --- a/social/backends/saml.py +++ b/social/backends/saml.py @@ -74,11 +74,6 @@ def sso_url(self): """ Get the SSO URL for this IdP """ return self.conf['url'] # Required. e.g. "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" - @property - def sso_binding(self): - """ Get the method used to submit our request to the SSO URL """ - return self.conf.get('binding', 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect') - @property def x509cert(self): """ X.509 Public Key Certificate for this IdP """ @@ -91,7 +86,7 @@ def saml_config_dict(self): "entityId": self.entity_id, "singleSignOnService": { "url": self.sso_url, - "binding": self.sso_binding, + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect", # python-saml only supports Redirect }, "x509cert": self.x509cert, } @@ -174,7 +169,7 @@ def generate_saml_config(self, idp): "sp": { "assertionConsumerService": { "url": abs_completion_url, - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", + "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST", # python-saml only supports HTTP-POST }, "entityId": self.setting("SP_ENTITY_ID"), "NameIDFormats": self.setting("SP_NAMEID_FORMATS", []), @@ -231,8 +226,8 @@ def auth_url(self): idp_name = self.strategy.request_data()['idp'] auth = self._create_saml_auth(idp=self.get_idp(idp_name)) # Below, return_to sets the RelayState, which can contain arbitrary data. - # We use it to store the specific SAML IdP backend name, since we combine - # many backends to a single URL. + # We use it to store the specific SAML IdP name, since we multiple IdPs + # share the same auth_complete URL. return auth.login(return_to=idp_name) def get_user_details(self, response): diff --git a/social/tests/backends/data/saml_config.json b/social/tests/backends/data/saml_config.json index 5c119e6a7..3f610107c 100644 --- a/social/tests/backends/data/saml_config.json +++ b/social/tests/backends/data/saml_config.json @@ -17,10 +17,7 @@ }, "other": { "entity_id": "https://unused.saml.example.com", - "singleSignOnService": { - "url": "https://unused.saml.example.com/SAML2/Redirect/SSO", - "binding": "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect" - } + "url": "https://unused.saml.example.com/SAML2/Redirect/SSO" } } } From 3a6be39a32874d032337fb7cd50f27c5cafcec17 Mon Sep 17 00:00:00 2001 From: Braden MacDonald Date: Thu, 18 Jun 2015 11:59:06 -0700 Subject: [PATCH 8/8] Added documentation --- docs/backends/saml.rst | 171 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 docs/backends/saml.rst diff --git a/docs/backends/saml.rst b/docs/backends/saml.rst new file mode 100644 index 000000000..72de2fd0d --- /dev/null +++ b/docs/backends/saml.rst @@ -0,0 +1,171 @@ +SAML +==== + +The SAML backend allows users to authenticate with any provider that supports +the SAML 2.0 protocol (commonly used for corporate or academic single sign on). + +The SAML backend for python-social-auth allows your web app to act as a SAML +Service Provider. You can configure one or more SAML Identity Providers that +users can use for authentication. For example, if your users are students, you +could enable Harvard and MIT as identity providers, so that students of either +of those two universities can use their campus login to access your app. + +Required Configuration +---------------------- + +At a minimum, you must add the following to your project's settings: + +- ``SOCIAL_AUTH_SAML_SP_ENTITY_ID``: The SAML Entity ID for your app. This + should be a URL that includes a domain name you own. It doesn't matter what + the URL points to. Example: ``http://saml.yoursite.com`` + +- ``SOCIAL_AUTH_SAML_SP_PUBLIC_CERT``: The X.509 certificate string for the + key pair that your app will use. You can generate a new self-signed key pair + with:: + + openssl req -new -x509 -days 3652 -nodes -out saml.crt -keyout saml.key + + The contents of ``saml.crt`` should then be used as the value of this setting + (you can omit the first and last lines, which aren't required). + +- ``SOCIAL_AUTH_SAML_SP_PRIVATE_KEY``: The private key to be used by your app. + If you used the example openssl command given above, set this to the contents + of ``saml.key`` (again, you can omit the first and last lines). + +- ``SOCIAL_AUTH_SAML_ORG_INFO``: A dictionary that contains information about + your app. You must specify values for English at a minimum. Each language's + entry should specify a ``name`` (not shown to the user), a ``displayname`` + (shown to the user), and a URL. See the following + example:: + + { + "en-US": { + "name": "example", + "displayname": "Example Inc.", + "url": "http://example.com", + } + } + +- ``SOCIAL_AUTH_SAML_TECHNICAL_CONTACT``: A dictionary with two values, + ``givenName`` and ``emailAddress``, describing the name and email of a + technical contact responsible for your app. Example:: + + {"givenName": "Tech Gal", "emailAddress": "technical@example.com"} + +- ``SOCIAL_AUTH_SAML_TECHNICAL_CONTACT``: A dictionary with two values, + ``givenName`` and ``emailAddress``, describing the name and email of a + support contact for your app. Example:: + + SOCIAL_AUTH_SAML_SUPPORT_CONTACT = { + "givenName": "Support Guy", + "emailAddress": "support@example.com", + } + +- ``SOCIAL_AUTH_SAML_ENABLED_IDPS``: The most important setting. List the Entity + ID, SSO URL, and x.509 public key certificate for each provider that your app + wants to support. The SSO URL must support the ``HTTP-Redirect`` binding. + You can get these values from the provider's XML metadata. Here's an example, + for TestShib_ (the values come from TestShib's metadata_):: + + { + "testshib": { + "entity_id": "https://idp.testshib.org/idp/shibboleth", + "url": "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO", + "x509cert": "MIIEDjCCAvagAwIBAgIBADA ... 8Bbnl+ev0peYzxFyF5sQA==", + } + } + +Basic Usage +----------- + +- Set all of the required configuration variables described above. + +- Generate the SAML XML metadata for your app. The best way to do this is to + create a new view/page/URL in your app that will call the backend's + ``generate_metadata_xml()`` method. Here's an example of how to do this in + Django:: + + def saml_metadata_view(request): + complete_url = reverse('social:complete', args=("saml", )) + saml_backend = load_backend( + load_strategy(request), + "saml", + redirect_uri=complete_url, + ) + metadata, errors = saml_backend.generate_metadata_xml() + if not errors: + return HttpResponse(content=metadata, content_type='text/xml') + +- Download the metadata for your app that was generated by the above method, + and send it to each Identity Provider (IdP) that you wish to use. Each IdP + must install and configure your metadata on their system before it will work. + +- Now everything is set! To allow users to login with any given IdP, you need to + give them a link to the python-social-auth "begin"/"auth" URL and include an + ``idp`` query parameter that specifies the name of the IdP to use. This is + needed since the backend supports multiple IdPs. The names of the IdPs are the + keys used in the ``SOCIAL_AUTH_SAML_ENABLED_IDPS`` setting. + + Django example:: + + # In view: + context['testshib_url'] = u"{base}?{params}".format( + base=reverse('social:begin', kwargs={'backend': 'saml'}), + params=urllib.urlencode({'next': '/home', 'idp': 'testshib'}) + ) + # In template: + TestShib Login + # Result: + TestShib Login + +- Testing with the TestShib_ provider is recommended, as it is known to work + well. + + +Advanced Settings +----------------- + +- ``SOCIAL_AUTH_SAML_SP_EXTRA``: This can be set to a dict, and any key/value + pairs specified here will be passed to the underlying ``python-saml`` library + configuration's ``sp`` setting. Refer to the ``python-saml`` documentation for + details. + +- ``SOCIAL_AUTH_SAML_SECURITY_CONFIG``: This can be set to a dict, and any + key/value pairs specified here will be passed to the underlying + ``python-saml`` library configuration's ``security`` setting. Two useful keys + that you can set are ``metadataCacheDuration`` and ``metadataValidUntil``, + which control the expiry time of your XML metadata. By default, a cache + duration of 10 days will be used, which means that IdPs are allowed to cache + your metadata for up to 10 days, but no longer. ``metadataCacheDuration`` must + be specified as an ISO 8601 duration string (e.g. `P1D` for one day). + +- ``SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS``: This is a list of ``NameID`` formats + accepted by your app. The default is not to specify any. Example:: + + SOCIAL_AUTH_SAML_SP_NAMEID_FORMATS = [ + 'urn:oasis:names:tc:SAML:2.0:nameid-format:persistent', + 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress', + ] + + +Advanced Usage +-------------- + +You can subclass the ``SAMLAuth`` backend to provide custom functionality. In +particular, there are two methods that are designed for subclasses to override: + +- ``get_idp(self, idp_name)``: Given the name of an IdP, return an instance of + ``SAMLIdentityProvider`` with the details of the IdP. Override this method if + you wish to use some other method for configuring the available identity + providers, such as fetching them at runtime from another server, or using a + list of providers from a Shibboleth federation. + +- ``_check_entitlements(self, idp, attributes)``: This method gets called during + the login process and is where you can decide to accept or reject a user based + on the user's SAML attributes. For example, you can restrict access to your + application to only accept users who belong to a certain department. After + inspecting the passed attributes parameter, do nothing to allow the user to + login, or raise ``social.exceptions.AuthForbidden`` to reject the user. + +.. _TestShib: https://www.testshib.org/ +.. _metadata: https://www.testshib.org/metadata/testshib-providers.xml