diff --git a/keystoneclient/contrib/auth/__init__.py b/keystoneclient/contrib/auth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keystoneclient/contrib/auth/v3/__init__.py b/keystoneclient/contrib/auth/v3/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/keystoneclient/contrib/auth/v3/saml2.py b/keystoneclient/contrib/auth/v3/saml2.py new file mode 100644 index 000000000..6434bd435 --- /dev/null +++ b/keystoneclient/contrib/auth/v3/saml2.py @@ -0,0 +1,411 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +from lxml import etree +from oslo.config import cfg + +from keystoneclient import access +from keystoneclient.auth.identity import v3 +from keystoneclient import exceptions + + +class Saml2UnscopedTokenAuthMethod(v3.AuthMethod): + _method_parameters = [] + + def get_auth_data(self, session, auth, headers, **kwargs): + raise exceptions.MethodNotImplemented(('This method should never ' + 'be called')) + + +class Saml2UnscopedToken(v3.AuthConstructor): + """Implement authentication plugin for SAML2 protocol. + + ECP stands for ``Enhanced Client or Proxy`` and is a SAML2 extension + for federated authentication where a transportation layer consists of + HTTP protocol and XML SOAP messages. + + Read for more information:: + ``https://wiki.shibboleth.net/confluence/display/SHIB2/ECP`` + + The SAML2 ECP specification can be found at:: + ``https://www.oasis-open.org/committees/download.php/ + 49979/saml-ecp-v2.0-wd09.pdf`` + + Currently only HTTPBasicAuth mechanism is available for the IdP + authenication. + + """ + + _auth_method_class = Saml2UnscopedTokenAuthMethod + + PROTOCOL = 'saml2' + HTTP_MOVED_TEMPORARILY = 302 + SAML2_HEADER_INDEX = 0 + ECP_SP_EMPTY_REQUEST_HEADERS = { + 'Accept': 'text/html; application/vnd.paos+xml', + 'PAOS': ('ver="urn:liberty:paos:2003-08";"urn:oasis:names:tc:' + 'SAML:2.0:profiles:SSO:ecp"') + } + + ECP_SP_SAML2_REQUEST_HEADERS = { + 'Content-Type': 'application/vnd.paos+xml' + } + + ECP_SAML2_NAMESPACES = { + 'ecp': 'urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp', + 'S': 'http://schemas.xmlsoap.org/soap/envelope/', + 'paos': 'urn:liberty:paos:2003-08' + } + + ECP_RELAY_STATE = '//ecp:RelayState' + + ECP_SERVICE_PROVIDER_CONSUMER_URL = ('/S:Envelope/S:Header/paos:Request/' + '@responseConsumerURL') + + ECP_IDP_CONSUMER_URL = ('/S:Envelope/S:Header/ecp:Response/' + '@AssertionConsumerServiceURL') + + SOAP_FAULT = """ + + + + S:Server + responseConsumerURL from SP and + assertionConsumerServiceURL from IdP do not match + + + + + """ + + def __init__(self, auth_url, + identity_provider, + identity_provider_url, + username, password, + **kwargs): + """Class constructor accepting following parameters: + :param auth_url: URL of the Identity Service + :type auth_url: string + + :param identity_provider: name of the Identity Provider the client + will authenticate against. This parameter + will be used to build a dynamic URL used to + obtain unscoped OpenStack token. + :type identity_provider: string + + :param identity_provider_url: An Identity Provider URL, where the SAML2 + authn request will be sent. + :type identity_provider_url: string + + :param username: User's login + :type username: string + + :param password: User's password + :type password: string + + """ + super(Saml2UnscopedToken, self).__init__(auth_url=auth_url, **kwargs) + self.identity_provider = identity_provider + self.identity_provider_url = identity_provider_url + self.username, self.password = username, password + + @classmethod + def get_options(cls): + options = super(Saml2UnscopedToken, cls).get_options() + options.extend([ + cfg.StrOpt('identity-provider', help="Identity Provider's name"), + cfg.StrOpt('identity-provider-url', + help="Identity Provider's URL"), + cfg.StrOpt('user-name', dest='username', help='Username', + deprecated_name='username'), + cfg.StrOpt('password', help='Password') + ]) + return options + + def _handle_http_302_ecp_redirect(self, session, response, method, + **kwargs): + if response.status_code != self.HTTP_MOVED_TEMPORARILY: + return response + + location = response.headers['location'] + return session.request(location, method, **kwargs) + + def _first(self, _list): + if len(_list) != 1: + raise IndexError("Only single element list can be flatten") + return _list[0] + + def _prepare_idp_saml2_request(self, saml2_authn_request): + header = saml2_authn_request[self.SAML2_HEADER_INDEX] + saml2_authn_request.remove(header) + + def _check_consumer_urls(self, session, sp_response_consumer_url, + idp_sp_response_consumer_url): + """Check if consumer URLs issued by SP and IdP are equal. + + In the initial SAML2 authn Request issued by a Service Provider + there is a url called ``consumer url``. A trusted Identity Provider + should issue identical url. If the URLs are not equal the federated + authn process should be interrupted and the user should be warned. + + :param session: session object to send out HTTP requests. + :type session: keystoneclient.session.Session + :param sp_response_consumer_url: consumer URL issued by a SP + :type sp_response_consumer_url: string + :param idp_sp_response_consumer_url: consumer URL issued by an IdP + :type idp_sp_response_consumer_url: string + + """ + if sp_response_consumer_url != idp_sp_response_consumer_url: + # send fault message to the SP, discard the response + session.post(sp_response_consumer_url, data=self.SOAP_FAULT, + headers=self.ECP_SP_SAML2_REQUEST_HEADERS, + authenticated=False) + + # prepare error message and raise an exception. + msg = ("Consumer URLs from Service Provider %(service_provider)s " + "%(sp_consumer_url)s and Identity Provider " + "%(identity_provider)s %(idp_consumer_url)s are not equal") + msg = msg % { + 'service_provider': self.saml2_token_url, + 'sp_consumer_url': sp_response_consumer_url, + 'identity_provider': self.identity_provider, + 'idp_consumer_url': idp_sp_response_consumer_url + } + + raise exceptions.ValidationError(msg) + + def _send_service_provider_request(self, session): + """Initial HTTP GET request to the SAML2 protected endpoint. + + It's crucial to include HTTP headers indicating that the client is + willing to take advantage of the ECP SAML2 extension and receive data + as the SOAP. + Unlike standard authentication methods in the OpenStack Identity, + the client accesses:: + ``/v3/OS-FEDERATION/identity_providers/{identity_providers}/ + protocols/{protocol}/auth`` + + After a successful HTTP call the HTTP response should include SAML2 + authn request in the XML format. + + If a HTTP response contains ``X-Subject-Token`` in the headers and + the response body is a valid JSON assume the user was already + authenticated and Keystone returned a valid unscoped token. + Return True indicating the user was already authenticated. + + :param session: a session object to send out HTTP requests. + :type session: keystoneclient.session.Session + + """ + sp_response = session.get(self.saml2_token_url, + headers=self.ECP_SP_EMPTY_REQUEST_HEADERS, + authenticated=False) + + if 'X-Subject-Token' in sp_response.headers: + self.authenticated_response = sp_response + return True + + try: + self.saml2_authn_request = etree.XML(sp_response.content) + except etree.XMLSyntaxError as e: + msg = ("SAML2: Error parsing XML returned " + "from Service Provider, reason: %s" % e) + raise exceptions.AuthorizationFailure(msg) + + relay_state = self.saml2_authn_request.xpath( + self.ECP_RELAY_STATE, namespaces=self.ECP_SAML2_NAMESPACES) + self.relay_state = self._first(relay_state) + + sp_response_consumer_url = self.saml2_authn_request.xpath( + self.ECP_SERVICE_PROVIDER_CONSUMER_URL, + namespaces=self.ECP_SAML2_NAMESPACES) + self.sp_response_consumer_url = self._first( + sp_response_consumer_url) + return False + + def _send_idp_saml2_authn_request(self, session): + """Present modified SAML2 authn assertion from the Service Provider.""" + + self._prepare_idp_saml2_request(self.saml2_authn_request) + idp_saml2_authn_request = self.saml2_authn_request + + # Currently HTTPBasicAuth method is hardcoded into the plugin + idp_response = session.post( + self.identity_provider_url, + headers={'Content-type': 'text/xml'}, + data=etree.tostring(idp_saml2_authn_request), + requests_auth=(self.username, self.password)) + + try: + self.saml2_idp_authn_response = etree.XML(idp_response.content) + except etree.XMLSyntaxError as e: + msg = ("SAML2: Error parsing XML returned " + "from Identity Provider, reason: %s" % e) + raise exceptions.AuthorizationFailure(msg) + + idp_response_consumer_url = self.saml2_idp_authn_response.xpath( + self.ECP_IDP_CONSUMER_URL, + namespaces=self.ECP_SAML2_NAMESPACES) + + self.idp_response_consumer_url = self._first( + idp_response_consumer_url) + + self._check_consumer_urls(session, self.idp_response_consumer_url, + self.sp_response_consumer_url) + + def _send_service_provider_saml2_authn_response(self, session): + """Present SAML2 assertion to the Service Provider. + + The assertion is issued by a trusted Identity Provider for the + authenticated user. This function directs the HTTP request to SP + managed URL, for instance: ``https://:/Shibboleth.sso/ + SAML2/ECP``. + Upon success the there's a session created and access to the protected + resource is granted. Many implementations of the SP return HTTP 302 + status code pointing to the protected URL (``https://:/v3/ + OS-FEDERATION/identity_providers/{identity_provider}/protocols/ + {protocol_id}/auth`` in this case). Saml2 plugin should point to that + URL again, with HTTP GET method, expecting an unscoped token. + + :param session: a session object to send out HTTP requests. + + """ + self.saml2_idp_authn_response[0][0] = self.relay_state + + response = session.post( + self.idp_response_consumer_url, + headers=self.ECP_SP_SAML2_REQUEST_HEADERS, + data=etree.tostring(self.saml2_idp_authn_response), + authenticated=False, redirect=False) + + # Don't follow HTTP specs - after the HTTP 302 response don't repeat + # the call directed to the Location URL. In this case, this is an + # indication that saml2 session is now active and protected resource + # can be accessed. + response = self._handle_http_302_ecp_redirect( + session, response, method='GET', + headers=self.ECP_SP_SAML2_REQUEST_HEADERS) + + self.authenticated_response = response + + @property + def saml2_token_url(self): + """Return full URL where authorization data is sent.""" + values = { + 'host': self.auth_url.rstrip('/'), + 'identity_provider': self.identity_provider, + 'protocol': self.PROTOCOL + } + url = ("%(host)s/OS-FEDERATION/identity_providers/" + "%(identity_provider)s/protocols/%(protocol)s/auth") + url = url % values + + return url + + def _get_unscoped_token(self, session, **kwargs): + """Get unscoped OpenStack token after federated authentication. + + This is a multi-step process including multiple HTTP requests. + + The federated authentication consists of:: + * HTTP GET request to the Identity Service (acting as a Service + Provider). Client utilizes URL:: + ``/v3/OS-FEDERATION/identity_providers/{identity_provider}/ + protocols/saml2/auth``. + It's crucial to include HTTP headers indicating we are expecting + SOAP message in return. + Service Provider should respond with such SOAP message. + This step is handed by a method + ``Saml2UnscopedToken_send_service_provider_request()`` + + * HTTP POST request to the external Identity Provider service with + ECP extension enabled. The content sent is a header removed SOAP + message returned from the Service Provider. It's also worth noting + that ECP extension to the SAML2 doesn't define authentication method. + The most popular is HttpBasicAuth with just user and password. + Other possibilities could be X509 certificates or Kerberos. + Upon successful authentication the user should receive a SAML2 + assertion. + This step is handed by a method + ``Saml2UnscopedToken_send_idp_saml2_authn_request(session)`` + + * HTTP POST request again to the Service Provider. The body of the + request includes SAML2 assertion issued by a trusted Identity + Provider. The request should be sent to the Service Provider + consumer url specified in the SAML2 assertion. + Providing the authentication was successful and both Service Provider + and Identity Providers are trusted to each other, the Service + Provider will issue an unscoped token with a list of groups the + federated user is a member of. + This step is handed by a method + ``Saml2UnscopedToken_send_service_provider_saml2_authn_response()`` + + Unscoped token example:: + + { + "token": { + "methods": [ + "saml2" + ], + "user": { + "id": "username%40example.com", + "name": "username@example.com", + "OS-FEDERATION": { + "identity_provider": "ACME", + "protocol": "saml2", + "groups": [ + {"id": "abc123"}, + {"id": "bcd234"} + ] + } + } + } + } + + + :param session : a session object to send out HTTP requests. + :type session: keystoneclient.session.Session + + :returns: (token, token_json) + + """ + saml_authenticated = self._send_service_provider_request(session) + if not saml_authenticated: + self._send_idp_saml2_authn_request(session) + self._send_service_provider_saml2_authn_response(session) + return (self.authenticated_response.headers['X-Subject-Token'], + self.authenticated_response.json()['token']) + + def get_auth_ref(self, session, **kwargs): + """Authenticate via SAML2 protocol and retrieve unscoped token. + + This is a multi-step process where a client does federated authn + receives an unscoped token. + + Federated authentication utilizing SAML2 Enhanced Client or Proxy + extension. See ``Saml2UnscopedToken_get_unscoped_token()`` + for more information on that step. + Upon successful authentication and assertion mapping an + unscoped token is returned and stored within the plugin object for + further use. + + :param session : a session object to send out HTTP requests. + :type session: keystoneclient.session.Session + + :return access.AccessInfoV3: an object with scoped token's id and + unscoped token json included. + + """ + token, token_json = self._get_unscoped_token(session, **kwargs) + return access.AccessInfoV3(token, + **token_json) diff --git a/keystoneclient/tests/v3/saml2_fixtures.py b/keystoneclient/tests/v3/saml2_fixtures.py new file mode 100644 index 000000000..3327120e6 --- /dev/null +++ b/keystoneclient/tests/v3/saml2_fixtures.py @@ -0,0 +1,121 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +SP_SOAP_RESPONSE = b""" + + + + +https://openstack4.local/shibboleth + + + + + +ss:mem:6f1f20fafbb38433467e9d477df67615 + + + https://openstack4.local/shibboleth + + + + """ + + +SAML2_ASSERTION = b""" + + + + + x= + + + + + +https://idp.testshib.org/idp/shibboleth + + + + + + + + + + + + + + +VALUE== + + +VALUE= + + +""" + +UNSCOPED_TOKEN_HEADER = 'UNSCOPED_TOKEN' + +UNSCOPED_TOKEN = { + "token": { + "issued_at": "2014-06-09T09:48:59.643406Z", + "extras": {}, + "methods": ["saml2"], + "expires_at": "2014-06-09T10:48:59.643375Z", + "user": { + "OS-FEDERATION": { + "identity_provider": { + "id": "testshib" + }, + "protocol": { + "id": "saml2" + }, + "groups": [ + {"id": "1764fa5cf69a49a4918131de5ce4af9a"} + ] + }, + "id": "testhib%20user", + "name": "testhib user" + } + } +} diff --git a/keystoneclient/tests/v3/test_auth_saml2.py b/keystoneclient/tests/v3/test_auth_saml2.py new file mode 100644 index 000000000..d77fe13f3 --- /dev/null +++ b/keystoneclient/tests/v3/test_auth_saml2.py @@ -0,0 +1,311 @@ +# Licensed under the Apache License, Version 2.0 (the "License"); you may +# not use this file except in compliance with the License. You may obtain +# a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT +# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the +# License for the specific language governing permissions and limitations +# under the License. + +import uuid + +import httpretty +from lxml import etree +import requests + +from keystoneclient.auth import conf +from keystoneclient.contrib.auth.v3 import saml2 +from keystoneclient import exceptions +from keystoneclient.openstack.common.fixture import config +from keystoneclient.openstack.common import jsonutils +from keystoneclient import session +from keystoneclient.tests.auth import utils as auth_utils +from keystoneclient.tests.v3 import saml2_fixtures +from keystoneclient.tests.v3 import utils + + +class AuthenticateviaSAML2Tests(auth_utils.TestCase, utils.TestCase): + + class _AuthenticatedResponse(object): + headers = { + 'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER + } + + def json(self): + return saml2_fixtures.UNSCOPED_TOKEN + + class _AuthenticatedResponseInvalidJson(_AuthenticatedResponse): + + def json(self): + raise ValueError() + + class _AuthentiatedResponseMissingTokenID(_AuthenticatedResponse): + headers = {} + + def setUp(self): + utils.TestCase.setUp(self) + auth_utils.TestCase.setUp(self) + + self.conf_fixture = auth_utils.TestCase.useFixture(self, + config.Config()) + conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP) + + self.session = session.Session(auth=None, verify=False, + session=requests.Session()) + self.ECP_SP_EMPTY_REQUEST_HEADERS = { + 'Accept': 'text/html; application/vnd.paos+xml', + 'PAOS': ('ver="urn:liberty:paos:2003-08";' + '"urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"') + } + + self.ECP_SP_SAML2_REQUEST_HEADERS = { + 'Content-Type': 'application/vnd.paos+xml' + } + + self.ECP_SAML2_NAMESPACES = { + 'ecp': 'urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp', + 'S': 'http://schemas.xmlsoap.org/soap/envelope/', + 'paos': 'urn:liberty:paos:2003-08' + } + self.ECP_RELAY_STATE = '//ecp:RelayState' + self.ECP_SERVICE_PROVIDER_CONSUMER_URL = ('/S:Envelope/S:Header/paos:' + 'Request/' + '@responseConsumerURL') + self.ECP_IDP_CONSUMER_URL = ('/S:Envelope/S:Header/ecp:Response/' + '@AssertionConsumerServiceURL') + self.IDENTITY_PROVIDER = 'testidp' + self.IDENTITY_PROVIDER_URL = 'http://local.url' + self.PROTOCOL = 'saml2' + self.FEDERATION_AUTH_URL = '%s/%s' % ( + self.TEST_URL, + 'OS-FEDERATION/identity_providers/testidp/protocols/saml2/auth') + self.SHIB_CONSUMER_URL = ('https://openstack4.local/' + 'Shibboleth.sso/SAML2/ECP') + + self.saml2plugin = saml2.Saml2UnscopedToken( + self.TEST_URL, + self.IDENTITY_PROVIDER, self.IDENTITY_PROVIDER_URL, + self.TEST_USER, self.TEST_TOKEN) + + def simple_http(self, method, url, body=b'', content_type=None, + headers=None, status=200, **kwargs): + self.stub_url(method, base_url=url, body=body, adding_headers=headers, + content_type=content_type, status=status, **kwargs) + + def make_oneline(self, s): + return etree.tostring(etree.XML(s)).replace(b'\n', b'') + + def test_conf_params(self): + section = uuid.uuid4().hex + identity_provider = uuid.uuid4().hex + identity_provider_url = uuid.uuid4().hex + username = uuid.uuid4().hex + password = uuid.uuid4().hex + self.conf_fixture.config(auth_section=section, group=self.GROUP) + conf.register_conf_options(self.conf_fixture.conf, group=self.GROUP) + + self.conf_fixture.register_opts(saml2.Saml2UnscopedToken.get_options(), + group=section) + self.conf_fixture.config(auth_plugin='v3unscopedsaml', + identity_provider=identity_provider, + identity_provider_url=identity_provider_url, + username=username, + password=password, + group=section) + + a = conf.load_from_conf_options(self.conf_fixture.conf, self.GROUP) + self.assertEqual(identity_provider, a.identity_provider) + self.assertEqual(identity_provider_url, a.identity_provider_url) + self.assertEqual(username, a.username) + self.assertEqual(password, a.password) + + @httpretty.activate + def test_initial_sp_call(self): + """Test initial call, expect SOAP message.""" + self.simple_http('GET', self.FEDERATION_AUTH_URL, + body=self.make_oneline( + saml2_fixtures.SP_SOAP_RESPONSE)) + a = self.saml2plugin._send_service_provider_request(self.session) + + self.assertFalse(a) + + fixture_soap_response = self.make_oneline( + saml2_fixtures.SP_SOAP_RESPONSE) + + sp_soap_response = self.make_oneline( + etree.tostring(self.saml2plugin.saml2_authn_request)) + + error_msg = "Expected %s instead of %s" % (fixture_soap_response, + sp_soap_response) + + self.assertEqual(fixture_soap_response, sp_soap_response, error_msg) + + self.assertEqual( + self.saml2plugin.sp_response_consumer_url, self.SHIB_CONSUMER_URL, + "Expected consumer_url set to %s instead of %s" % ( + self.SHIB_CONSUMER_URL, + str(self.saml2plugin.sp_response_consumer_url))) + + @httpretty.activate + def test_initial_sp_call_when_saml_authenticated(self): + + headers = {'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER} + self.simple_http('GET', self.FEDERATION_AUTH_URL, + body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN), + headers=headers) + a = self.saml2plugin._send_service_provider_request(self.session) + self.assertTrue(a) + self.assertEqual( + saml2_fixtures.UNSCOPED_TOKEN['token'], + self.saml2plugin.authenticated_response.json()['token']) + self.assertEqual( + saml2_fixtures.UNSCOPED_TOKEN_HEADER, + self.saml2plugin.authenticated_response.headers['X-Subject-Token']) + + @httpretty.activate + def test_get_unscoped_token_when_authenticated(self): + headers = {'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER} + self.simple_http('GET', self.FEDERATION_AUTH_URL, + body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN), + headers=headers) + token, token_body = self.saml2plugin._get_unscoped_token(self.session) + self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'], token_body) + + self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER, token) + + @httpretty.activate + def test_initial_sp_call_invalid_response(self): + """Send initial SP HTTP request and receive wrong server response.""" + self.simple_http('GET', self.FEDERATION_AUTH_URL, + body="NON XML RESPONSE") + + self.assertRaises( + exceptions.AuthorizationFailure, + self.saml2plugin._send_service_provider_request, + self.session) + + @httpretty.activate + def test_send_authn_req_to_idp(self): + self.simple_http('POST', self.IDENTITY_PROVIDER_URL, + body=saml2_fixtures.SAML2_ASSERTION) + + self.saml2plugin.sp_response_consumer_url = self.SHIB_CONSUMER_URL + self.saml2plugin.saml2_authn_request = etree.XML( + saml2_fixtures.SP_SOAP_RESPONSE) + self.saml2plugin._send_idp_saml2_authn_request(self.session) + + idp_response = self.make_oneline(etree.tostring( + self.saml2plugin.saml2_idp_authn_response)) + + saml2_assertion_oneline = self.make_oneline( + saml2_fixtures.SAML2_ASSERTION) + error = "Expected %s instead of %s" % (saml2_fixtures.SAML2_ASSERTION, + idp_response) + self.assertEqual(idp_response, saml2_assertion_oneline, error) + + @httpretty.activate + def test_fail_basicauth_idp_authentication(self): + self.simple_http('POST', self.IDENTITY_PROVIDER_URL, + status=401) + + self.saml2plugin.sp_response_consumer_url = self.SHIB_CONSUMER_URL + self.saml2plugin.saml2_authn_request = etree.XML( + saml2_fixtures.SP_SOAP_RESPONSE) + self.assertRaises( + exceptions.Unauthorized, + self.saml2plugin._send_idp_saml2_authn_request, + self.session) + + def test_mising_username_password_in_plugin(self): + self.assertRaises(TypeError, + saml2.Saml2UnscopedToken, + self.TEST_URL, self.IDENTITY_PROVIDER, + self.IDENTITY_PROVIDER_URL) + + @httpretty.activate + def test_send_authn_response_to_sp(self): + self.simple_http( + 'POST', self.SHIB_CONSUMER_URL, + body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN), + content_type='application/json', + status=200, + headers={'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER}) + + self.saml2plugin.relay_state = etree.XML( + saml2_fixtures.SP_SOAP_RESPONSE).xpath( + self.ECP_RELAY_STATE, namespaces=self.ECP_SAML2_NAMESPACES)[0] + + self.saml2plugin.saml2_idp_authn_response = etree.XML( + saml2_fixtures.SAML2_ASSERTION) + + self.saml2plugin.idp_response_consumer_url = self.SHIB_CONSUMER_URL + self.saml2plugin._send_service_provider_saml2_authn_response( + self.session) + token_json = self.saml2plugin.authenticated_response.json()['token'] + token = self.saml2plugin.authenticated_response.headers[ + 'X-Subject-Token'] + self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'], + token_json) + + self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER, + token) + + def test_consumer_url_mismatch_success(self): + self.saml2plugin._check_consumer_urls( + self.session, self.SHIB_CONSUMER_URL, + self.SHIB_CONSUMER_URL) + + @httpretty.activate + def test_consumer_url_mismatch(self): + self.simple_http('POST', self.SHIB_CONSUMER_URL) + invalid_consumer_url = uuid.uuid4().hex + self.assertRaises( + exceptions.ValidationError, + self.saml2plugin._check_consumer_urls, + self.session, self.SHIB_CONSUMER_URL, + invalid_consumer_url) + + @httpretty.activate + def test_custom_302_redirection(self): + self.simple_http('POST', self.SHIB_CONSUMER_URL, + body='BODY', + headers={'location': self.FEDERATION_AUTH_URL}, + status=302) + self.simple_http( + 'GET', self.FEDERATION_AUTH_URL, + body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN), + headers={'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER}) + self.session.redirect = False + response = self.session.post( + self.SHIB_CONSUMER_URL, data='CLIENT BODY') + self.assertEqual(302, response.status_code) + self.assertEqual(self.FEDERATION_AUTH_URL, + response.headers['location']) + + response = self.saml2plugin._handle_http_302_ecp_redirect( + self.session, response, 'GET') + + self.assertEqual(self.FEDERATION_AUTH_URL, response.request.url) + self.assertEqual('GET', response.request.method) + + @httpretty.activate + def test_end_to_end_workflow(self): + self.simple_http('GET', self.FEDERATION_AUTH_URL, + body=self.make_oneline( + saml2_fixtures.SP_SOAP_RESPONSE)) + self.simple_http('POST', self.IDENTITY_PROVIDER_URL, + body=saml2_fixtures.SAML2_ASSERTION) + self.simple_http( + 'POST', self.SHIB_CONSUMER_URL, + body=jsonutils.dumps(saml2_fixtures.UNSCOPED_TOKEN), + content_type='application/json', + status=200, + headers={'X-Subject-Token': saml2_fixtures.UNSCOPED_TOKEN_HEADER}) + + self.session.redirect = False + response = self.saml2plugin.get_auth_ref(self.session) + self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER, + response.auth_token) diff --git a/requirements.txt b/requirements.txt index 1dde3a683..133aa374f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,7 @@ argparse Babel>=1.3 iso8601>=0.1.9 +lxml>=2.3 netaddr>=0.7.6 oslo.config>=1.2.1 pbr>=0.6,!=0.7,<1.0 diff --git a/setup.cfg b/setup.cfg index 3e5593250..3d6b9af78 100644 --- a/setup.cfg +++ b/setup.cfg @@ -36,6 +36,7 @@ keystoneclient.auth.plugin = v2token = keystoneclient.auth.identity.v2:Token v3password = keystoneclient.auth.identity.v3:Password v3token = keystoneclient.auth.identity.v3:Token + v3unscopedsaml = keystoneclient.contrib.auth.v3.saml2:Saml2UnscopedToken [build_sphinx] source-dir = doc/source