saml: Figure out the idp from SAMLResponse.

Instead of plumbing the idp to /complete/saml/ through redis, it's much
more natural to just figure it out from the SAMLResponse, because the
information is there.
This is also a preparatory step for adding IdP-initiated sign in, for
which it is important for /complete/saml/ to be able to figure out which
IdP the request is coming from.
This commit is contained in:
Mateusz Mandera 2020-05-22 18:44:29 +02:00 committed by Tim Abbott
parent c74f8363e2
commit dac4a7a70b
3 changed files with 94 additions and 21 deletions

View File

@ -1,4 +1,4 @@
<?xml version="1.0" encoding="UTF-8"?><saml2p:Response Destination="http://zulip.testserver/complete/saml/" ID="id544612569720442296425226" InResponseTo="ONELOGIN_a5fde8b09598814d7af2537f865d31c2f7aea831" IssueInstant="2019-09-25T01:02:02.120Z" Version="2.0" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">http://www.okta.com/exk1da4osrIL3Y7ip357</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id544612569720442296425226"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces PrefixList="xs" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>P/e+Gdz179UAcrrPZW2R9hzxMlSAGwbZ+Ogksp7Rzlg=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>H1eepG122h3jzIqorofI6sr636xVFFtqsN0Vj5eb9YoFN3KMDH1AqzvGbzA+XEoT/1vle/D2n1A0qMv6UrnMy0EgrZlA+Mx3MgcQDhFoIqI7lV48I2aJ+G1+FvTrzt1hhfn6SBTorhc3M2+ST9z68V8mLsNXr82GveL/Ej5J4rxbQQ0Jxaic3luAkV0EhROqiSDwC7e/45II34e3sdtQ9bbnf3feDbovklb7Daa/NIqWpWX+0Y9qhHo1zx05oPiGZFtveJHiUbFXPpjR0r1juuG3HTGkORhRHCMYnpz73NsmuBTkAgYE+G0vUr0k5Sk28efS15ZZuAyiN+XCjl6SzQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDojCCAoqgAwIBAgIGAW0svbVqMA0GCSqGSIb3DQEBCwUAMIGRMQswCQYDVQQGEwJVUzETMBEG <?xml version="1.0" encoding="UTF-8"?><saml2p:Response Destination="http://zulip.testserver/complete/saml/" ID="id544612569720442296425226" InResponseTo="ONELOGIN_a5fde8b09598814d7af2537f865d31c2f7aea831" IssueInstant="2019-09-25T01:02:02.120Z" Version="2.0" xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:xs="http://www.w3.org/2001/XMLSchema"><saml2:Issuer Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity" xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">https://idp.testshib.org/idp/shibboleth</saml2:Issuer><ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#"><ds:SignedInfo><ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/><ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/><ds:Reference URI="#id544612569720442296425226"><ds:Transforms><ds:Transform Algorithm="http://www.w3.org/2000/09/xmldsig#enveloped-signature"/><ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"><ec:InclusiveNamespaces PrefixList="xs" xmlns:ec="http://www.w3.org/2001/10/xml-exc-c14n#"/></ds:Transform></ds:Transforms><ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/><ds:DigestValue>P/e+Gdz179UAcrrPZW2R9hzxMlSAGwbZ+Ogksp7Rzlg=</ds:DigestValue></ds:Reference></ds:SignedInfo><ds:SignatureValue>H1eepG122h3jzIqorofI6sr636xVFFtqsN0Vj5eb9YoFN3KMDH1AqzvGbzA+XEoT/1vle/D2n1A0qMv6UrnMy0EgrZlA+Mx3MgcQDhFoIqI7lV48I2aJ+G1+FvTrzt1hhfn6SBTorhc3M2+ST9z68V8mLsNXr82GveL/Ej5J4rxbQQ0Jxaic3luAkV0EhROqiSDwC7e/45II34e3sdtQ9bbnf3feDbovklb7Daa/NIqWpWX+0Y9qhHo1zx05oPiGZFtveJHiUbFXPpjR0r1juuG3HTGkORhRHCMYnpz73NsmuBTkAgYE+G0vUr0k5Sk28efS15ZZuAyiN+XCjl6SzQ==</ds:SignatureValue><ds:KeyInfo><ds:X509Data><ds:X509Certificate>MIIDojCCAoqgAwIBAgIGAW0svbVqMA0GCSqGSIb3DQEBCwUAMIGRMQswCQYDVQQGEwJVUzETMBEG
A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU A1UECAwKQ2FsaWZvcm5pYTEWMBQGA1UEBwwNU2FuIEZyYW5jaXNjbzENMAsGA1UECgwET2t0YTEU
MBIGA1UECwwLU1NPUHJvdmlkZXIxEjAQBgNVBAMMCXp1bGlwY2hhdDEcMBoGCSqGSIb3DQEJARYN MBIGA1UECwwLU1NPUHJvdmlkZXIxEjAQBgNVBAMMCXp1bGlwY2hhdDEcMBoGCSqGSIb3DQEJARYN
aW5mb0Bva3RhLmNvbTAeFw0xOTA5MTMyMjI3MTNaFw0yOTA5MTMyMjI4MTNaMIGRMQswCQYDVQQG aW5mb0Bva3RhLmNvbTAeFw0xOTA5MTMyMjI3MTNaFw0yOTA5MTMyMjI4MTNaMIGRMQswCQYDVQQG

View File

@ -1505,7 +1505,7 @@ class SAMLAuthBackendTest(SocialAuthBase):
mock.patch('zproject.backends.logging.info') as m: mock.patch('zproject.backends.logging.info') as m:
# This mock causes AuthFailed to be raised. # This mock causes AuthFailed to be raised.
saml_response = self.generate_saml_response(self.email, self.name) saml_response = self.generate_saml_response(self.email, self.name)
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"SAMLResponse": saml_response, "RelayState": relay_state} post_params = {"SAMLResponse": saml_response, "RelayState": relay_state}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
@ -1518,7 +1518,7 @@ class SAMLAuthBackendTest(SocialAuthBase):
side_effect=AuthStateForbidden('State forbidden')), \ side_effect=AuthStateForbidden('State forbidden')), \
mock.patch('zproject.backends.logging.warning') as m: mock.patch('zproject.backends.logging.warning') as m:
saml_response = self.generate_saml_response(self.email, self.name) saml_response = self.generate_saml_response(self.email, self.name)
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"SAMLResponse": saml_response, "RelayState": relay_state} post_params = {"SAMLResponse": saml_response, "RelayState": relay_state}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
@ -1538,40 +1538,40 @@ class SAMLAuthBackendTest(SocialAuthBase):
# Check that POSTing the RelayState, but with missing SAMLResponse, # Check that POSTing the RelayState, but with missing SAMLResponse,
# doesn't cause errors either: # doesn't cause errors either:
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"RelayState": relay_state} post_params = {"RelayState": relay_state}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url) self.assertIn('login', result.url)
m.assert_called_once() m.assert_called_once_with('/complete/saml/: No SAMLResponse in request.')
# Now test bad SAMLResponses. # Now test bad SAMLResponses.
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"RelayState": relay_state, 'SAMLResponse': ''} post_params = {"RelayState": relay_state, 'SAMLResponse': ''}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url) self.assertIn('login', result.url)
m.assert_called_once() m.assert_called()
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"RelayState": relay_state, 'SAMLResponse': 'b'} post_params = {"RelayState": relay_state, 'SAMLResponse': 'b'}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url) self.assertIn('login', result.url)
m.assert_called_once() m.assert_called()
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"RelayState": relay_state, 'SAMLResponse': 'dGVzdA=='} # base64 encoded 'test' post_params = {"RelayState": relay_state, 'SAMLResponse': 'dGVzdA=='} # base64 encoded 'test'
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url) self.assertIn('login', result.url)
m.assert_called_once() m.assert_called()
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
relay_state = SAMLAuthBackend.put_data_in_redis({"idp": "test_idp", "subdomain": "zulip"}) relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
relay_state = relay_state[:-1] # Break the token by removing the last character relay_state = relay_state[:-1] # Break the token by removing the last character
post_params = {"RelayState": relay_state} post_params = {"RelayState": relay_state}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
@ -1579,16 +1579,54 @@ class SAMLAuthBackendTest(SocialAuthBase):
self.assertIn('login', result.url) self.assertIn('login', result.url)
m.assert_called_with("SAML authentication failed: bad RelayState token.") m.assert_called_with("SAML authentication failed: bad RelayState token.")
def test_social_auth_complete_no_idp_and_subdomain(self) -> None: def test_social_auth_complete_no_subdomain(self) -> None:
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
relay_state = SAMLAuthBackend.put_data_in_redis({}) relay_state = SAMLAuthBackend.put_data_in_redis({})
post_params = {"RelayState": relay_state} post_params = {"RelayState": relay_state,
'SAMLResponse': self.generate_saml_response(email=self.example_email("hamlet"),
name="King Hamlet")}
result = self.client_post('/complete/saml/', post_params) result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302) self.assertEqual(result.status_code, 302)
self.assertEqual('/login/', result.url) self.assertEqual('/login/', result.url)
self.assertIn("Missing idp or subdomain value in relayed_params", self.assertIn("/complete/saml/: Missing subdomain value in relayed_params.",
m.call_args.args[0]) m.call_args.args[0])
def test_social_auth_complete_wrong_issuing_idp(self) -> None:
relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
saml_response = self.generate_saml_response(email=self.example_email("hamlet"),
name="King Hamlet")
# We change the entity_id of the configured test IdP, which means it won't match
# the Entity ID in the SAMLResponse generated above.
idps_dict = copy.deepcopy(settings.SOCIAL_AUTH_SAML_ENABLED_IDPS)
idps_dict['test_idp']['entity_id'] = 'https://different.idp.example.com/'
with self.settings(SOCIAL_AUTH_SAML_ENABLED_IDPS=idps_dict):
with mock.patch('zproject.backends.logging.info') as m:
post_params = {"RelayState": relay_state,
"SAMLResponse": saml_response}
result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302)
self.assertEqual('/login/', result.url)
self.assertIn("/complete/saml/: No valid IdP as issuer of the SAMLResponse.",
m.call_args.args[0])
def test_social_auth_complete_valid_get_idp_bad_samlresponse(self) -> None:
"""
This tests for a hypothetical scenario where our basic parsing of the SAMLResponse
successfully returns the issuing IdP, but it fails further down the line, during proper
validation in the underlying libraries.
"""
with mock.patch('zproject.backends.logging.info') as m, \
mock.patch.object(SAMLAuthBackend, 'get_issuing_idp', return_value='test_idp'):
relay_state = SAMLAuthBackend.put_data_in_redis({"subdomain": "zulip"})
post_params = {"RelayState": relay_state, 'SAMLResponse': 'dGVzdA=='}
result = self.client_post('/complete/saml/', post_params)
self.assertEqual(result.status_code, 302)
self.assertIn('login', result.url)
m.assert_called_once()
def test_social_auth_saml_bad_idp_param_on_login_page(self) -> None: def test_social_auth_saml_bad_idp_param_on_login_page(self) -> None:
with mock.patch('zproject.backends.logging.info') as m: with mock.patch('zproject.backends.logging.info') as m:
result = self.client_get('/login/saml/') result = self.client_get('/login/saml/')

View File

@ -39,6 +39,8 @@ from django.utils.translation import ugettext as _
from lxml.etree import XMLSyntaxError from lxml.etree import XMLSyntaxError
from requests import HTTPError from requests import HTTPError
from onelogin.saml2.errors import OneLogin_Saml2_Error from onelogin.saml2.errors import OneLogin_Saml2_Error
from onelogin.saml2.response import OneLogin_Saml2_Response
from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \ from social_core.backends.github import GithubOAuth2, GithubOrganizationOAuth2, \
GithubTeamOAuth2 GithubTeamOAuth2
from social_core.backends.azuread import AzureADOAuth2 from social_core.backends.azuread import AzureADOAuth2
@ -1484,6 +1486,7 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
standard_relay_params = ["subdomain", "multiuse_object_key", "mobile_flow_otp", "desktop_flow_otp", standard_relay_params = ["subdomain", "multiuse_object_key", "mobile_flow_otp", "desktop_flow_otp",
"next", "is_signup"] "next", "is_signup"]
REDIS_EXPIRATION_SECONDS = 60 * 15 REDIS_EXPIRATION_SECONDS = 60 * 15
SAMLRESPONSE_PARSING_EXCEPTIONS = (OneLogin_Saml2_Error, binascii.Error, XMLSyntaxError)
name = "saml" name = "saml"
# Organization which go through the trouble of setting up SAML are most likely # Organization which go through the trouble of setting up SAML are most likely
# to have it as their main authentication method, so it seems appropriate to have # to have it as their main authentication method, so it seems appropriate to have
@ -1537,7 +1540,7 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
# RelayState, which is used as a key into our redis data store # RelayState, which is used as a key into our redis data store
# for fetching the actual parameters after the IdP has # for fetching the actual parameters after the IdP has
# returned a successful authentication. # returned a successful authentication.
params_to_relay = ["idp"] + self.standard_relay_params params_to_relay = self.standard_relay_params
request_data = self.strategy.request_data().dict() request_data = self.strategy.request_data().dict()
data_to_relay = { data_to_relay = {
key: request_data[key] for key in params_to_relay if key in request_data key: request_data[key] for key in params_to_relay if key in request_data
@ -1565,6 +1568,30 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
return data return data
@classmethod
def get_issuing_idp(cls, SAMLResponse: str) -> Optional[str]:
"""
Given a SAMLResponse, returns which of the configured IdPs is declared as the issuer.
This value MUST NOT be trusted as the true issuer!
The signatures are not validated, so it can be tampered with by the user.
That's not a problem for this function,
and true validation happens later in the underlying libraries, but it's important
to note this detail. The purpose of this function is merely as a helper to figure out which
of the configured IdPs' information to use for parsing and validating the response.
"""
try:
resp = OneLogin_Saml2_Response(settings={}, response=SAMLResponse)
issuers = resp.get_issuers()
except cls.SAMLRESPONSE_PARSING_EXCEPTIONS as e:
logging.info("Error while parsing SAMLResponse: %s: %s", e.__class__.__name__, e)
return None
for idp_name, idp_config in settings.SOCIAL_AUTH_SAML_ENABLED_IDPS.items():
if idp_config['entity_id'] in issuers:
return idp_name
return None
def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]: def auth_complete(self, *args: Any, **kwargs: Any) -> Optional[HttpResponse]:
""" """
Additional ugly wrapping on top of auth_complete in SocialAuthMixin. Additional ugly wrapping on top of auth_complete in SocialAuthMixin.
@ -1589,11 +1616,19 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
if relayed_params is None: if relayed_params is None:
return None return None
idp_name = relayed_params.get("idp") SAMLResponse = self.strategy.request_data().get('SAMLResponse')
if SAMLResponse is None:
logging.info("/complete/saml/: No SAMLResponse in request.")
return None
idp_name = self.get_issuing_idp(SAMLResponse)
if idp_name is None:
logging.info("/complete/saml/: No valid IdP as issuer of the SAMLResponse.")
return None
subdomain = relayed_params.get("subdomain") subdomain = relayed_params.get("subdomain")
if idp_name is None or subdomain is None: if subdomain is None:
error_msg = "Missing idp or subdomain value in relayed_params in SAML auth_complete: %s" logging.info("/complete/saml/: Missing subdomain value in relayed_params.")
logging.info(error_msg, dict(subdomain=subdomain, idp=idp_name))
return None return None
idp_valid = self.validate_idp_for_subdomain(idp_name, subdomain) idp_valid = self.validate_idp_for_subdomain(idp_name, subdomain)
@ -1617,7 +1652,7 @@ class SAMLAuthBackend(SocialAuthMixin, SAMLAuth):
# Call the auth_complete method of SocialAuthMixIn # Call the auth_complete method of SocialAuthMixIn
result = super().auth_complete(*args, **kwargs) result = super().auth_complete(*args, **kwargs)
except (OneLogin_Saml2_Error, binascii.Error, XMLSyntaxError) as e: except self.SAMLRESPONSE_PARSING_EXCEPTIONS as e:
# These can be raised if SAMLResponse is missing or badly formatted. # These can be raised if SAMLResponse is missing or badly formatted.
logging.info("/complete/saml/: %s: %s", e.__class__.__name__, e) logging.info("/complete/saml/: %s: %s", e.__class__.__name__, e)
# Fall through to returning None. # Fall through to returning None.