From f147a1b0958e36b06aaa6935b0eef8dbe69a0f52 Mon Sep 17 00:00:00 2001 From: Jamie Lennox Date: Tue, 3 Nov 2015 14:03:32 +1100 Subject: [PATCH] Split ADFS and SAML2 plugins There is a fair bit of code here. Split the ADFS and SAML2 plugins into their own files so that they are easier to refactor. Change-Id: I76b0d6e7a0dd54d09ef8ed1633e9c85924a9228c --- keystoneauth1/extras/_saml2/v3/__init__.py | 820 +----------------- keystoneauth1/extras/_saml2/v3/adfs.py | 426 +++++++++ keystoneauth1/extras/_saml2/v3/base.py | 94 ++ keystoneauth1/extras/_saml2/v3/saml2.py | 336 +++++++ .../tests/unit/extras/saml2/test_auth_adfs.py | 249 ++++++ ...dentity_v3_saml2.py => test_auth_saml2.py} | 264 +----- .../tests/unit/extras/saml2/utils.py | 39 + 7 files changed, 1156 insertions(+), 1072 deletions(-) create mode 100644 keystoneauth1/extras/_saml2/v3/adfs.py create mode 100644 keystoneauth1/extras/_saml2/v3/base.py create mode 100644 keystoneauth1/extras/_saml2/v3/saml2.py create mode 100644 keystoneauth1/tests/unit/extras/saml2/test_auth_adfs.py rename keystoneauth1/tests/unit/extras/saml2/{test_identity_v3_saml2.py => test_auth_saml2.py} (51%) create mode 100644 keystoneauth1/tests/unit/extras/saml2/utils.py diff --git a/keystoneauth1/extras/_saml2/v3/__init__.py b/keystoneauth1/extras/_saml2/v3/__init__.py index 3018d41f..ac534f29 100644 --- a/keystoneauth1/extras/_saml2/v3/__init__.py +++ b/keystoneauth1/extras/_saml2/v3/__init__.py @@ -10,822 +10,10 @@ # License for the specific language governing permissions and limitations # under the License. -import datetime -import logging -import uuid +from keystoneauth1.extras._saml2.v3 import adfs +from keystoneauth1.extras._saml2.v3 import saml2 -from lxml import etree -from six.moves import urllib - -from keystoneauth1 import access -from keystoneauth1 import exceptions -from keystoneauth1.identity import v3 -from keystoneauth1.identity.v3 import federation +Saml2Password = saml2.Password +ADFSPassword = adfs.Password __all__ = ('Saml2Password', 'ADFSPassword') - -LOG = logging.getLogger(__name__) - - -class _BaseSAMLPlugin(federation.FederationBaseAuth): - - HTTP_MOVED_TEMPORARILY = 302 - HTTP_SEE_OTHER = 303 - - def __init__(self, auth_url, - identity_provider, identity_provider_url, - username, password, protocol, - **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 - - :param protocol: Protocol to be used for the authentication. - The name must be equal to one configured at the - keystone sp side. This value is used for building - dynamic authentication URL. - Typical value would be: saml2 - :type protocol: string - - """ - super(_BaseSAMLPlugin, self).__init__( - auth_url=auth_url, identity_provider=identity_provider, - protocol=protocol, - **kwargs) - self.identity_provider_url = identity_provider_url - self.username = username - self.password = password - - @staticmethod - def _first(_list): - if len(_list) != 1: - raise IndexError('Only single element list is acceptable') - return _list[0] - - @staticmethod - def str_to_xml(content, msg=None, include_exc=True): - try: - return etree.XML(content) - except etree.XMLSyntaxError as e: - if not msg: - msg = str(e) - else: - msg = msg % e if include_exc else msg - raise exceptions.AuthorizationFailure(msg) - - @staticmethod - def xml_to_str(content, **kwargs): - return etree.tostring(content, **kwargs) - - -class Saml2AuthMethod(v3.AuthMethod): - _method_parameters = [] - - def get_auth_data(self, session, auth, headers, **kwargs): - raise exceptions.MethodNotImplemented('This method should never ' - 'be called') - - -class Saml2Password(_BaseSAMLPlugin): - """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 - `_ on ECP. - - Reference the `SAML2 ECP specification `_. - - Currently only HTTPBasicAuth mechanism is available for the IdP - authenication. - - :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 - - - :param protocol: Protocol to be used for the authentication. - The name must be equal to one configured at the - keystone sp side. This value is used for building - dynamic authentication URL. - Typical value would be: saml2 - :type protocol: string - - """ - - _auth_method_class = Saml2AuthMethod - - 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 _handle_http_ecp_redirect(self, session, response, method, **kwargs): - if response.status_code not in (self.HTTP_MOVED_TEMPORARILY, - self.HTTP_SEE_OTHER): - return response - - location = response.headers['location'] - return session.request(location, method, authenticated=False, - **kwargs) - - 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: keystoneauth1.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.federated_token_url, - 'sp_consumer_url': sp_response_consumer_url, - 'identity_provider': self.identity_provider, - 'idp_consumer_url': idp_sp_response_consumer_url - } - - raise exceptions.AuthorizationFailure(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: keystoneauth1.session.Session - - """ - sp_response = session.get(self.federated_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), - authenticated=False, log=False) - - 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/303 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_ecp_redirect( - session, response, method='GET', - headers=self.ECP_SP_SAML2_REQUEST_HEADERS) - - self.authenticated_response = response - - def get_unscoped_auth_ref(self, session): - """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). - - 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 - ``Saml2Password_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 - ``Saml2Password_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 - ``Saml2Password_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: keystoneauth1.session.Session - - :returns: AccessInfo - :rtype: :py:class:`keystoneauth1.access.AccessInfo` - """ - 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 access.create(resp=self.authenticated_response) - - -class ADFSPassword(_BaseSAMLPlugin): - """Authentication plugin for Microsoft ADFS2.0 IdPs.""" - - _auth_method_class = Saml2AuthMethod - - DEFAULT_ADFS_TOKEN_EXPIRATION = 120 - - HEADER_SOAP = {"Content-Type": "application/soap+xml; charset=utf-8"} - HEADER_X_FORM = {"Content-Type": "application/x-www-form-urlencoded"} - - NAMESPACES = { - 's': 'http://www.w3.org/2003/05/soap-envelope', - 'a': 'http://www.w3.org/2005/08/addressing', - 'u': ('http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-utility-1.0.xsd') - } - - ADFS_TOKEN_NAMESPACES = { - 's': 'http://www.w3.org/2003/05/soap-envelope', - 't': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512' - } - ADFS_ASSERTION_XPATH = ('/s:Envelope/s:Body' - '/t:RequestSecurityTokenResponseCollection' - '/t:RequestSecurityTokenResponse') - - def __init__(self, auth_url, identity_provider, identity_provider_url, - service_provider_endpoint, username, password, - protocol, **kwargs): - """Constructor for ``ADFSPassword``. - - :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 - authentication request will be sent. - :type identity_provider_url: string - - :param service_provider_endpoint: Endpoint where an assertion is being - sent, for instance: ``https://host.domain/Shibboleth.sso/ADFS`` - :type service_provider_endpoint: string - - :param username: User's login - :type username: string - - :param password: User's password - :type password: string - - """ - - super(ADFSPassword, self).__init__( - auth_url=auth_url, identity_provider=identity_provider, - identity_provider_url=identity_provider_url, - username=username, password=password, protocol=protocol) - - self.service_provider_endpoint = service_provider_endpoint - - def _cookies(self, session): - """Check if cookie jar is not empty. - - keystoneauth1.session.Session object doesn't have a cookies attribute. - We should then try fetching cookies from the underlying - requests.Session object. If that fails too, there is something wrong - and let Python raise the AttributeError. - - :param session - :returns: True if cookie jar is nonempty, False otherwise - :raises AttributeError: in case cookies are not find anywhere - - """ - try: - return bool(session.cookies) - except AttributeError: - pass - - return bool(session.session.cookies) - - def _token_dates(self, fmt='%Y-%m-%dT%H:%M:%S.%fZ'): - """Calculate created and expires datetime objects. - - The method is going to be used for building ADFS Request Security - Token message. Time interval between ``created`` and ``expires`` - dates is now static and equals to 120 seconds. ADFS security tokens - should not be live too long, as currently ``keystoneauth1`` - doesn't have mechanisms for reusing such tokens (every time ADFS authn - method is called, keystoneauth1 will login with the ADFS instance). - - :param fmt: Datetime format for specifying string format of a date. - It should not be changed if the method is going to be used - for building the ADFS security token request. - :type fmt: string - - """ - - date_created = datetime.datetime.utcnow() - date_expires = date_created + datetime.timedelta( - seconds=self.DEFAULT_ADFS_TOKEN_EXPIRATION) - return [_time.strftime(fmt) for _time in (date_created, date_expires)] - - def _prepare_adfs_request(self): - """Build the ADFS Request Security Token SOAP message. - - Some values like username or password are inserted in the request. - - """ - - WSS_SECURITY_NAMESPACE = { - 'o': ('http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-secext-1.0.xsd') - } - - TRUST_NAMESPACE = { - 'trust': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512' - } - - WSP_NAMESPACE = { - 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy' - } - - WSA_NAMESPACE = { - 'wsa': 'http://www.w3.org/2005/08/addressing' - } - - root = etree.Element( - '{http://www.w3.org/2003/05/soap-envelope}Envelope', - nsmap=self.NAMESPACES) - - header = etree.SubElement( - root, '{http://www.w3.org/2003/05/soap-envelope}Header') - action = etree.SubElement( - header, "{http://www.w3.org/2005/08/addressing}Action") - action.set( - "{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1") - action.text = ('http://docs.oasis-open.org/ws-sx/ws-trust/200512' - '/RST/Issue') - - messageID = etree.SubElement( - header, '{http://www.w3.org/2005/08/addressing}MessageID') - messageID.text = 'urn:uuid:' + uuid.uuid4().hex - replyID = etree.SubElement( - header, '{http://www.w3.org/2005/08/addressing}ReplyTo') - address = etree.SubElement( - replyID, '{http://www.w3.org/2005/08/addressing}Address') - address.text = 'http://www.w3.org/2005/08/addressing/anonymous' - - to = etree.SubElement( - header, '{http://www.w3.org/2005/08/addressing}To') - to.set("{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1") - - security = etree.SubElement( - header, '{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-secext-1.0.xsd}Security', - nsmap=WSS_SECURITY_NAMESPACE) - - security.set( - "{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1") - - timestamp = etree.SubElement( - security, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-utility-1.0.xsd}Timestamp')) - timestamp.set( - ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-utility-1.0.xsd}Id'), '_0') - - created = etree.SubElement( - timestamp, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-utility-1.0.xsd}Created')) - - expires = etree.SubElement( - timestamp, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-utility-1.0.xsd}Expires')) - - created.text, expires.text = self._token_dates() - - usernametoken = etree.SubElement( - security, '{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' - 'wss-wssecurity-secext-1.0.xsd}UsernameToken') - usernametoken.set( - ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-' - 'wssecurity-utility-1.0.xsd}u'), "uuid-%s-1" % uuid.uuid4().hex) - - username = etree.SubElement( - usernametoken, ('{http://docs.oasis-open.org/wss/2004/01/oasis-' - '200401-wss-wssecurity-secext-1.0.xsd}Username')) - password = etree.SubElement( - usernametoken, ('{http://docs.oasis-open.org/wss/2004/01/oasis-' - '200401-wss-wssecurity-secext-1.0.xsd}Password'), - Type=('http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-' - 'username-token-profile-1.0#PasswordText')) - - body = etree.SubElement( - root, "{http://www.w3.org/2003/05/soap-envelope}Body") - - request_security_token = etree.SubElement( - body, ('{http://docs.oasis-open.org/ws-sx/ws-trust/200512}' - 'RequestSecurityToken'), nsmap=TRUST_NAMESPACE) - - applies_to = etree.SubElement( - request_security_token, - '{http://schemas.xmlsoap.org/ws/2004/09/policy}AppliesTo', - nsmap=WSP_NAMESPACE) - - endpoint_reference = etree.SubElement( - applies_to, - '{http://www.w3.org/2005/08/addressing}EndpointReference', - nsmap=WSA_NAMESPACE) - - wsa_address = etree.SubElement( - endpoint_reference, - '{http://www.w3.org/2005/08/addressing}Address') - - keytype = etree.SubElement( - request_security_token, - '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}KeyType') - keytype.text = ('http://docs.oasis-open.org/ws-sx/' - 'ws-trust/200512/Bearer') - - request_type = etree.SubElement( - request_security_token, - '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}RequestType') - request_type.text = ('http://docs.oasis-open.org/ws-sx/' - 'ws-trust/200512/Issue') - token_type = etree.SubElement( - request_security_token, - '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}TokenType') - token_type.text = 'urn:oasis:names:tc:SAML:1.0:assertion' - - # After constructing the request, let's plug in some values - username.text = self.username - password.text = self.password - to.text = self.identity_provider_url - wsa_address.text = self.service_provider_endpoint - - self.prepared_request = root - - def _get_adfs_security_token(self, session): - """Send ADFS Security token to the ADFS server. - - Store the result in the instance attribute and raise an exception in - case the response is not valid XML data. - - If a user cannot authenticate due to providing bad credentials, the - ADFS2.0 server will return a HTTP 500 response and a XML Fault message. - If ``exceptions.InternalServerError`` is caught, the method tries to - parse the XML response. - If parsing is unsuccessful, an ``exceptions.AuthorizationFailure`` is - raised with a reason from the XML fault. Otherwise an original - ``exceptions.InternalServerError`` is re-raised. - - :param session : a session object to send out HTTP requests. - :type session: keystoneauth1.session.Session - - :raises keystoneauth1.exceptions.AuthorizationFailure: when HTTP - response from the ADFS server is not a valid XML ADFS security - token. - :raises keystoneauth1.exceptions.InternalServerError: If response - status code is HTTP 500 and the response XML cannot be - recognized. - - """ - def _get_failure(e): - xpath = '/s:Envelope/s:Body/s:Fault/s:Code/s:Subcode/s:Value' - content = e.response.content - try: - obj = self.str_to_xml(content).xpath( - xpath, namespaces=self.NAMESPACES) - obj = self._first(obj) - return obj.text - # NOTE(marek-denis): etree.Element.xpath() doesn't raise an - # exception, it just returns an empty list. In that case, _first() - # will raise IndexError and we should treat it as an indication XML - # is not valid. exceptions.AuthorizationFailure can be raised from - # str_to_xml(), however since server returned HTTP 500 we should - # re-raise exceptions.InternalServerError. - except (IndexError, exceptions.AuthorizationFailure): - raise e - - request_security_token = self.xml_to_str(self.prepared_request) - try: - response = session.post( - url=self.identity_provider_url, headers=self.HEADER_SOAP, - data=request_security_token, authenticated=False) - except exceptions.InternalServerError as e: - reason = _get_failure(e) - raise exceptions.AuthorizationFailure(reason) - msg = ('Error parsing XML returned from ' - 'the ADFS Identity Provider, reason: %s') - self.adfs_token = self.str_to_xml(response.content, msg) - - def _prepare_sp_request(self): - """Prepare ADFS Security Token to be sent to the Service Provider. - - The method works as follows: - * Extract SAML2 assertion from the ADFS Security Token. - * Replace namespaces - * urlencode assertion - * concatenate static string with the encoded assertion - - """ - assertion = self.adfs_token.xpath( - self.ADFS_ASSERTION_XPATH, namespaces=self.ADFS_TOKEN_NAMESPACES) - assertion = self._first(assertion) - assertion = self.xml_to_str(assertion) - # TODO(marek-denis): Ideally no string replacement should occur. - # Unfortunately lxml doesn't allow for namespaces changing in-place and - # probably the only solution good for now is to build the assertion - # from scratch and reuse values from the adfs security token. - assertion = assertion.replace( - b'http://docs.oasis-open.org/ws-sx/ws-trust/200512', - b'http://schemas.xmlsoap.org/ws/2005/02/trust') - - encoded_assertion = urllib.parse.quote(assertion) - self.encoded_assertion = 'wa=wsignin1.0&wresult=' + encoded_assertion - - def _send_assertion_to_service_provider(self, session): - """Send prepared assertion to a service provider. - - As the assertion doesn't contain a protected resource, the value from - the ``location`` header is not valid and we should not let the Session - object get redirected there. The aim of this call is to get a cookie in - the response which is required for entering a protected endpoint. - - :param session : a session object to send out HTTP requests. - :type session: keystoneauth1.session.Session - - :raises: Corresponding HTTP error exception - - """ - session.post( - url=self.service_provider_endpoint, data=self.encoded_assertion, - headers=self.HEADER_X_FORM, redirect=False, authenticated=False) - - def _access_service_provider(self, session): - """Access protected endpoint and fetch unscoped token. - - After federated authentication workflow a protected endpoint should be - accessible with the session object. The access is granted basing on the - cookies stored within the session object. If, for some reason no - cookies are present (quantity test) it means something went wrong and - user will not be able to fetch an unscoped token. In that case an - ``exceptions.AuthorizationFailure` exception is raised and no HTTP call - is even made. - - :param session : a session object to send out HTTP requests. - :type session: keystoneauth1.session.Session - - :raises keystoneauth1.exceptions.AuthorizationFailure: in case session - object has empty cookie jar. - - """ - if self._cookies(session) is False: - raise exceptions.AuthorizationFailure( - "Session object doesn't contain a cookie, therefore you are " - "not allowed to enter the Identity Provider's protected area.") - self.authenticated_response = session.get(self.federated_token_url, - authenticated=False) - - def get_unscoped_auth_ref(self, session, *kwargs): - """Retrieve unscoped token after authentcation with ADFS server. - - This is a multistep process: - - * Prepare ADFS Request Securty Token - - build a etree.XML object filling certain attributes with proper user - credentials, created/expires dates (ticket is be valid for 120 - seconds as currently we don't handle reusing ADFS issued security - tokens). - - * Send ADFS Security token to the ADFS server. Step handled by - - * Receive and parse security token, extract actual SAML assertion and - prepare a request addressed for the Service Provider endpoint. - This also includes changing namespaces in the XML document. Step - handled by ``ADFSPassword._prepare_sp_request()`` method. - - * Send prepared assertion to the Service Provider endpoint. Usually - the server will respond with HTTP 301 code which should be ignored as - the 'location' header doesn't contain protected area. The goal of - this operation is fetching the session cookie which later allows for - accessing protected URL endpoints. Step handed by - ``ADFSPassword._send_assertion_to_service_provider()`` method. - - * Once the session cookie is issued, the protected endpoint can be - accessed and an unscoped token can be retrieved. Step handled by - ``ADFSPassword._access_service_provider()`` method. - - :param session: a session object to send out HTTP requests. - :type session: keystoneauth1.session.Session - - :returns: AccessInfo - :rtype: :py:class:`keystoneauth1.access.AccessInfo` - - """ - self._prepare_adfs_request() - self._get_adfs_security_token(session) - self._prepare_sp_request() - self._send_assertion_to_service_provider(session) - self._access_service_provider(session) - - return access.create(resp=self.authenticated_response) diff --git a/keystoneauth1/extras/_saml2/v3/adfs.py b/keystoneauth1/extras/_saml2/v3/adfs.py new file mode 100644 index 00000000..76a38084 --- /dev/null +++ b/keystoneauth1/extras/_saml2/v3/adfs.py @@ -0,0 +1,426 @@ +# 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 datetime +import uuid + +from lxml import etree +from six.moves import urllib + +from keystoneauth1 import access +from keystoneauth1 import exceptions +from keystoneauth1.extras._saml2.v3 import base + + +class Password(base.BaseSAMLPlugin): + """Authentication plugin for Microsoft ADFS2.0 IdPs.""" + + DEFAULT_ADFS_TOKEN_EXPIRATION = 120 + + HEADER_SOAP = {"Content-Type": "application/soap+xml; charset=utf-8"} + HEADER_X_FORM = {"Content-Type": "application/x-www-form-urlencoded"} + + NAMESPACES = { + 's': 'http://www.w3.org/2003/05/soap-envelope', + 'a': 'http://www.w3.org/2005/08/addressing', + 'u': ('http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-utility-1.0.xsd') + } + + ADFS_TOKEN_NAMESPACES = { + 's': 'http://www.w3.org/2003/05/soap-envelope', + 't': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512' + } + ADFS_ASSERTION_XPATH = ('/s:Envelope/s:Body' + '/t:RequestSecurityTokenResponseCollection' + '/t:RequestSecurityTokenResponse') + + def __init__(self, auth_url, identity_provider, identity_provider_url, + service_provider_endpoint, username, password, + protocol, **kwargs): + """Constructor for ``ADFSPassword``. + + :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 + authentication request will be sent. + :type identity_provider_url: string + + :param service_provider_endpoint: Endpoint where an assertion is being + sent, for instance: ``https://host.domain/Shibboleth.sso/ADFS`` + :type service_provider_endpoint: string + + :param username: User's login + :type username: string + + :param password: User's password + :type password: string + + """ + + super(Password, self).__init__( + auth_url=auth_url, identity_provider=identity_provider, + identity_provider_url=identity_provider_url, + username=username, password=password, protocol=protocol) + + self.service_provider_endpoint = service_provider_endpoint + + def _cookies(self, session): + """Check if cookie jar is not empty. + + keystoneauth1.session.Session object doesn't have a cookies attribute. + We should then try fetching cookies from the underlying + requests.Session object. If that fails too, there is something wrong + and let Python raise the AttributeError. + + :param session + :returns: True if cookie jar is nonempty, False otherwise + :raises AttributeError: in case cookies are not find anywhere + + """ + try: + return bool(session.cookies) + except AttributeError: + pass + + return bool(session.session.cookies) + + def _token_dates(self, fmt='%Y-%m-%dT%H:%M:%S.%fZ'): + """Calculate created and expires datetime objects. + + The method is going to be used for building ADFS Request Security + Token message. Time interval between ``created`` and ``expires`` + dates is now static and equals to 120 seconds. ADFS security tokens + should not be live too long, as currently ``keystoneauth1`` + doesn't have mechanisms for reusing such tokens (every time ADFS authn + method is called, keystoneauth1 will login with the ADFS instance). + + :param fmt: Datetime format for specifying string format of a date. + It should not be changed if the method is going to be used + for building the ADFS security token request. + :type fmt: string + + """ + + date_created = datetime.datetime.utcnow() + date_expires = date_created + datetime.timedelta( + seconds=self.DEFAULT_ADFS_TOKEN_EXPIRATION) + return [_time.strftime(fmt) for _time in (date_created, date_expires)] + + def _prepare_adfs_request(self): + """Build the ADFS Request Security Token SOAP message. + + Some values like username or password are inserted in the request. + + """ + + WSS_SECURITY_NAMESPACE = { + 'o': ('http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-secext-1.0.xsd') + } + + TRUST_NAMESPACE = { + 'trust': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512' + } + + WSP_NAMESPACE = { + 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy' + } + + WSA_NAMESPACE = { + 'wsa': 'http://www.w3.org/2005/08/addressing' + } + + root = etree.Element( + '{http://www.w3.org/2003/05/soap-envelope}Envelope', + nsmap=self.NAMESPACES) + + header = etree.SubElement( + root, '{http://www.w3.org/2003/05/soap-envelope}Header') + action = etree.SubElement( + header, "{http://www.w3.org/2005/08/addressing}Action") + action.set( + "{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1") + action.text = ('http://docs.oasis-open.org/ws-sx/ws-trust/200512' + '/RST/Issue') + + messageID = etree.SubElement( + header, '{http://www.w3.org/2005/08/addressing}MessageID') + messageID.text = 'urn:uuid:' + uuid.uuid4().hex + replyID = etree.SubElement( + header, '{http://www.w3.org/2005/08/addressing}ReplyTo') + address = etree.SubElement( + replyID, '{http://www.w3.org/2005/08/addressing}Address') + address.text = 'http://www.w3.org/2005/08/addressing/anonymous' + + to = etree.SubElement( + header, '{http://www.w3.org/2005/08/addressing}To') + to.set("{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1") + + security = etree.SubElement( + header, '{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-secext-1.0.xsd}Security', + nsmap=WSS_SECURITY_NAMESPACE) + + security.set( + "{http://www.w3.org/2003/05/soap-envelope}mustUnderstand", "1") + + timestamp = etree.SubElement( + security, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-utility-1.0.xsd}Timestamp')) + timestamp.set( + ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-utility-1.0.xsd}Id'), '_0') + + created = etree.SubElement( + timestamp, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-utility-1.0.xsd}Created')) + + expires = etree.SubElement( + timestamp, ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-utility-1.0.xsd}Expires')) + + created.text, expires.text = self._token_dates() + + usernametoken = etree.SubElement( + security, '{http://docs.oasis-open.org/wss/2004/01/oasis-200401-' + 'wss-wssecurity-secext-1.0.xsd}UsernameToken') + usernametoken.set( + ('{http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-' + 'wssecurity-utility-1.0.xsd}u'), "uuid-%s-1" % uuid.uuid4().hex) + + username = etree.SubElement( + usernametoken, ('{http://docs.oasis-open.org/wss/2004/01/oasis-' + '200401-wss-wssecurity-secext-1.0.xsd}Username')) + password = etree.SubElement( + usernametoken, ('{http://docs.oasis-open.org/wss/2004/01/oasis-' + '200401-wss-wssecurity-secext-1.0.xsd}Password'), + Type=('http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-' + 'username-token-profile-1.0#PasswordText')) + + body = etree.SubElement( + root, "{http://www.w3.org/2003/05/soap-envelope}Body") + + request_security_token = etree.SubElement( + body, ('{http://docs.oasis-open.org/ws-sx/ws-trust/200512}' + 'RequestSecurityToken'), nsmap=TRUST_NAMESPACE) + + applies_to = etree.SubElement( + request_security_token, + '{http://schemas.xmlsoap.org/ws/2004/09/policy}AppliesTo', + nsmap=WSP_NAMESPACE) + + endpoint_reference = etree.SubElement( + applies_to, + '{http://www.w3.org/2005/08/addressing}EndpointReference', + nsmap=WSA_NAMESPACE) + + wsa_address = etree.SubElement( + endpoint_reference, + '{http://www.w3.org/2005/08/addressing}Address') + + keytype = etree.SubElement( + request_security_token, + '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}KeyType') + keytype.text = ('http://docs.oasis-open.org/ws-sx/' + 'ws-trust/200512/Bearer') + + request_type = etree.SubElement( + request_security_token, + '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}RequestType') + request_type.text = ('http://docs.oasis-open.org/ws-sx/' + 'ws-trust/200512/Issue') + token_type = etree.SubElement( + request_security_token, + '{http://docs.oasis-open.org/ws-sx/ws-trust/200512}TokenType') + token_type.text = 'urn:oasis:names:tc:SAML:1.0:assertion' + + # After constructing the request, let's plug in some values + username.text = self.username + password.text = self.password + to.text = self.identity_provider_url + wsa_address.text = self.service_provider_endpoint + + self.prepared_request = root + + def _get_adfs_security_token(self, session): + """Send ADFS Security token to the ADFS server. + + Store the result in the instance attribute and raise an exception in + case the response is not valid XML data. + + If a user cannot authenticate due to providing bad credentials, the + ADFS2.0 server will return a HTTP 500 response and a XML Fault message. + If ``exceptions.InternalServerError`` is caught, the method tries to + parse the XML response. + If parsing is unsuccessful, an ``exceptions.AuthorizationFailure`` is + raised with a reason from the XML fault. Otherwise an original + ``exceptions.InternalServerError`` is re-raised. + + :param session : a session object to send out HTTP requests. + :type session: keystoneauth1.session.Session + + :raises keystoneauth1.exceptions.AuthorizationFailure: when HTTP + response from the ADFS server is not a valid XML ADFS security + token. + :raises keystoneauth1.exceptions.InternalServerError: If response + status code is HTTP 500 and the response XML cannot be + recognized. + + """ + def _get_failure(e): + xpath = '/s:Envelope/s:Body/s:Fault/s:Code/s:Subcode/s:Value' + content = e.response.content + try: + obj = self.str_to_xml(content).xpath( + xpath, namespaces=self.NAMESPACES) + obj = self._first(obj) + return obj.text + # NOTE(marek-denis): etree.Element.xpath() doesn't raise an + # exception, it just returns an empty list. In that case, _first() + # will raise IndexError and we should treat it as an indication XML + # is not valid. exceptions.AuthorizationFailure can be raised from + # str_to_xml(), however since server returned HTTP 500 we should + # re-raise exceptions.InternalServerError. + except (IndexError, exceptions.AuthorizationFailure): + raise e + + request_security_token = self.xml_to_str(self.prepared_request) + try: + response = session.post( + url=self.identity_provider_url, headers=self.HEADER_SOAP, + data=request_security_token, authenticated=False) + except exceptions.InternalServerError as e: + reason = _get_failure(e) + raise exceptions.AuthorizationFailure(reason) + msg = ('Error parsing XML returned from ' + 'the ADFS Identity Provider, reason: %s') + self.adfs_token = self.str_to_xml(response.content, msg) + + def _prepare_sp_request(self): + """Prepare ADFS Security Token to be sent to the Service Provider. + + The method works as follows: + * Extract SAML2 assertion from the ADFS Security Token. + * Replace namespaces + * urlencode assertion + * concatenate static string with the encoded assertion + + """ + assertion = self.adfs_token.xpath( + self.ADFS_ASSERTION_XPATH, namespaces=self.ADFS_TOKEN_NAMESPACES) + assertion = self._first(assertion) + assertion = self.xml_to_str(assertion) + # TODO(marek-denis): Ideally no string replacement should occur. + # Unfortunately lxml doesn't allow for namespaces changing in-place and + # probably the only solution good for now is to build the assertion + # from scratch and reuse values from the adfs security token. + assertion = assertion.replace( + b'http://docs.oasis-open.org/ws-sx/ws-trust/200512', + b'http://schemas.xmlsoap.org/ws/2005/02/trust') + + encoded_assertion = urllib.parse.quote(assertion) + self.encoded_assertion = 'wa=wsignin1.0&wresult=' + encoded_assertion + + def _send_assertion_to_service_provider(self, session): + """Send prepared assertion to a service provider. + + As the assertion doesn't contain a protected resource, the value from + the ``location`` header is not valid and we should not let the Session + object get redirected there. The aim of this call is to get a cookie in + the response which is required for entering a protected endpoint. + + :param session : a session object to send out HTTP requests. + :type session: keystoneauth1.session.Session + + :raises: Corresponding HTTP error exception + + """ + session.post( + url=self.service_provider_endpoint, data=self.encoded_assertion, + headers=self.HEADER_X_FORM, redirect=False, authenticated=False) + + def _access_service_provider(self, session): + """Access protected endpoint and fetch unscoped token. + + After federated authentication workflow a protected endpoint should be + accessible with the session object. The access is granted basing on the + cookies stored within the session object. If, for some reason no + cookies are present (quantity test) it means something went wrong and + user will not be able to fetch an unscoped token. In that case an + ``exceptions.AuthorizationFailure` exception is raised and no HTTP call + is even made. + + :param session : a session object to send out HTTP requests. + :type session: keystoneauth1.session.Session + + :raises keystoneauth1.exceptions.AuthorizationFailure: in case session + object has empty cookie jar. + + """ + if self._cookies(session) is False: + raise exceptions.AuthorizationFailure( + "Session object doesn't contain a cookie, therefore you are " + "not allowed to enter the Identity Provider's protected area.") + self.authenticated_response = session.get(self.federated_token_url, + authenticated=False) + + def get_unscoped_auth_ref(self, session, *kwargs): + """Retrieve unscoped token after authentcation with ADFS server. + + This is a multistep process: + + * Prepare ADFS Request Securty Token - + build a etree.XML object filling certain attributes with proper user + credentials, created/expires dates (ticket is be valid for 120 + seconds as currently we don't handle reusing ADFS issued security + tokens). + + * Send ADFS Security token to the ADFS server. Step handled by + + * Receive and parse security token, extract actual SAML assertion and + prepare a request addressed for the Service Provider endpoint. + This also includes changing namespaces in the XML document. Step + handled by ``ADFSPassword._prepare_sp_request()`` method. + + * Send prepared assertion to the Service Provider endpoint. Usually + the server will respond with HTTP 301 code which should be ignored as + the 'location' header doesn't contain protected area. The goal of + this operation is fetching the session cookie which later allows for + accessing protected URL endpoints. Step handed by + ``ADFSPassword._send_assertion_to_service_provider()`` method. + + * Once the session cookie is issued, the protected endpoint can be + accessed and an unscoped token can be retrieved. Step handled by + ``ADFSPassword._access_service_provider()`` method. + + :param session: a session object to send out HTTP requests. + :type session: keystoneauth1.session.Session + + :returns: AccessInfo + :rtype: :py:class:`keystoneauth1.access.AccessInfo` + + """ + self._prepare_adfs_request() + self._get_adfs_security_token(session) + self._prepare_sp_request() + self._send_assertion_to_service_provider(session) + self._access_service_provider(session) + + return access.create(resp=self.authenticated_response) diff --git a/keystoneauth1/extras/_saml2/v3/base.py b/keystoneauth1/extras/_saml2/v3/base.py new file mode 100644 index 00000000..ee25e395 --- /dev/null +++ b/keystoneauth1/extras/_saml2/v3/base.py @@ -0,0 +1,94 @@ +# 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 keystoneauth1 import exceptions +from keystoneauth1.identity import v3 + + +class _Saml2TokenAuthMethod(v3.AuthMethod): + _method_parameters = [] + + def get_auth_data(self, session, auth, headers, **kwargs): + raise exceptions.MethodNotImplemented('This method should never ' + 'be called') + + +class BaseSAMLPlugin(v3.FederationBaseAuth): + + HTTP_MOVED_TEMPORARILY = 302 + HTTP_SEE_OTHER = 303 + + _auth_method_class = _Saml2TokenAuthMethod + + def __init__(self, auth_url, + identity_provider, identity_provider_url, + username, password, protocol, + **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 + + :param protocol: Protocol to be used for the authentication. + The name must be equal to one configured at the + keystone sp side. This value is used for building + dynamic authentication URL. + Typical value would be: saml2 + :type protocol: string + + """ + super(BaseSAMLPlugin, self).__init__( + auth_url=auth_url, identity_provider=identity_provider, + protocol=protocol, + **kwargs) + self.identity_provider_url = identity_provider_url + self.username = username + self.password = password + + @staticmethod + def _first(_list): + if len(_list) != 1: + raise IndexError('Only single element list is acceptable') + return _list[0] + + @staticmethod + def str_to_xml(content, msg=None, include_exc=True): + try: + return etree.XML(content) + except etree.XMLSyntaxError as e: + if not msg: + msg = str(e) + else: + msg = msg % e if include_exc else msg + raise exceptions.AuthorizationFailure(msg) + + @staticmethod + def xml_to_str(content, **kwargs): + return etree.tostring(content, **kwargs) diff --git a/keystoneauth1/extras/_saml2/v3/saml2.py b/keystoneauth1/extras/_saml2/v3/saml2.py new file mode 100644 index 00000000..543ecb49 --- /dev/null +++ b/keystoneauth1/extras/_saml2/v3/saml2.py @@ -0,0 +1,336 @@ +# 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 keystoneauth1 import access +from keystoneauth1 import exceptions +from keystoneauth1.extras._saml2.v3 import base + + +class Password(base.BaseSAMLPlugin): + """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 + `_ on ECP. + + Reference the `SAML2 ECP specification `_. + + Currently only HTTPBasicAuth mechanism is available for the IdP + authenication. + + :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 + + + :param protocol: Protocol to be used for the authentication. + The name must be equal to one configured at the + keystone sp side. This value is used for building + dynamic authentication URL. + Typical value would be: saml2 + :type protocol: string + + """ + + 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 _handle_http_ecp_redirect(self, session, response, method, **kwargs): + if response.status_code not in (self.HTTP_MOVED_TEMPORARILY, + self.HTTP_SEE_OTHER): + return response + + location = response.headers['location'] + return session.request(location, method, authenticated=False, + **kwargs) + + 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: keystoneauth1.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.federated_token_url, + 'sp_consumer_url': sp_response_consumer_url, + 'identity_provider': self.identity_provider, + 'idp_consumer_url': idp_sp_response_consumer_url + } + + raise exceptions.AuthorizationFailure(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: keystoneauth1.session.Session + + """ + sp_response = session.get(self.federated_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), + authenticated=False, log=False) + + 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/303 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_ecp_redirect( + session, response, method='GET', + headers=self.ECP_SP_SAML2_REQUEST_HEADERS) + + self.authenticated_response = response + + def get_unscoped_auth_ref(self, session): + """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). + + 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 + ``Saml2Password_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 + ``Saml2Password_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 + ``Saml2Password_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: keystoneauth1.session.Session + + :returns: AccessInfo + :rtype: :py:class:`keystoneauth1.access.AccessInfo` + """ + 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 access.create(resp=self.authenticated_response) diff --git a/keystoneauth1/tests/unit/extras/saml2/test_auth_adfs.py b/keystoneauth1/tests/unit/extras/saml2/test_auth_adfs.py new file mode 100644 index 00000000..a56cfcb6 --- /dev/null +++ b/keystoneauth1/tests/unit/extras/saml2/test_auth_adfs.py @@ -0,0 +1,249 @@ +# 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 + +from lxml import etree +from six.moves import urllib + +from keystoneauth1 import exceptions +from keystoneauth1.extras import _saml2 as saml2 +from keystoneauth1.tests.unit import client_fixtures +from keystoneauth1.tests.unit.extras.saml2 import fixtures as saml2_fixtures +from keystoneauth1.tests.unit.extras.saml2 import utils + + +class AuthenticateviaADFSTests(utils.TestCase): + + GROUP = 'auth' + + NAMESPACES = { + 's': 'http://www.w3.org/2003/05/soap-envelope', + 'trust': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512', + 'wsa': 'http://www.w3.org/2005/08/addressing', + 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy', + 'a': 'http://www.w3.org/2005/08/addressing', + 'o': ('http://docs.oasis-open.org/wss/2004/01/oasis' + '-200401-wss-wssecurity-secext-1.0.xsd') + } + + USER_XPATH = ('/s:Envelope/s:Header' + '/o:Security' + '/o:UsernameToken' + '/o:Username') + PASSWORD_XPATH = ('/s:Envelope/s:Header' + '/o:Security' + '/o:UsernameToken' + '/o:Password') + ADDRESS_XPATH = ('/s:Envelope/s:Body' + '/trust:RequestSecurityToken' + '/wsp:AppliesTo/wsa:EndpointReference' + '/wsa:Address') + TO_XPATH = ('/s:Envelope/s:Header' + '/a:To') + + TEST_TOKEN = uuid.uuid4().hex + + PROTOCOL = 'saml2' + + @property + def _uuid4(self): + return '4b911420-4982-4009-8afc-5c596cd487f5' + + def setUp(self): + super(AuthenticateviaADFSTests, self).setUp() + + self.IDENTITY_PROVIDER = 'adfs' + self.IDENTITY_PROVIDER_URL = ('http://adfs.local/adfs/service/trust/13' + '/usernamemixed') + self.FEDERATION_AUTH_URL = '%s/%s' % ( + self.TEST_URL, + 'OS-FEDERATION/identity_providers/adfs/protocols/saml2/auth') + self.SP_ENDPOINT = 'https://openstack4.local/Shibboleth.sso/ADFS' + + self.adfsplugin = saml2.V3ADFSPassword( + self.TEST_URL, self.IDENTITY_PROVIDER, + self.IDENTITY_PROVIDER_URL, self.SP_ENDPOINT, + self.TEST_USER, self.TEST_TOKEN, self.PROTOCOL) + + self.ADFS_SECURITY_TOKEN_RESPONSE = utils._load_xml( + 'ADFS_RequestSecurityTokenResponse.xml') + self.ADFS_FAULT = utils._load_xml('ADFS_fault.xml') + + def test_get_adfs_security_token(self): + """Test ADFSPassword._get_adfs_security_token().""" + + self.requests_mock.post( + self.IDENTITY_PROVIDER_URL, + content=utils.make_oneline(self.ADFS_SECURITY_TOKEN_RESPONSE), + status_code=200) + + self.adfsplugin._prepare_adfs_request() + self.adfsplugin._get_adfs_security_token(self.session) + + adfs_response = etree.tostring(self.adfsplugin.adfs_token) + fixture_response = self.ADFS_SECURITY_TOKEN_RESPONSE + + self.assertEqual(fixture_response, adfs_response) + + def test_adfs_request_user(self): + self.adfsplugin._prepare_adfs_request() + user = self.adfsplugin.prepared_request.xpath( + self.USER_XPATH, namespaces=self.NAMESPACES)[0] + self.assertEqual(self.TEST_USER, user.text) + + def test_adfs_request_password(self): + self.adfsplugin._prepare_adfs_request() + password = self.adfsplugin.prepared_request.xpath( + self.PASSWORD_XPATH, namespaces=self.NAMESPACES)[0] + self.assertEqual(self.TEST_TOKEN, password.text) + + def test_adfs_request_to(self): + self.adfsplugin._prepare_adfs_request() + to = self.adfsplugin.prepared_request.xpath( + self.TO_XPATH, namespaces=self.NAMESPACES)[0] + self.assertEqual(self.IDENTITY_PROVIDER_URL, to.text) + + def test_prepare_adfs_request_address(self): + self.adfsplugin._prepare_adfs_request() + address = self.adfsplugin.prepared_request.xpath( + self.ADDRESS_XPATH, namespaces=self.NAMESPACES)[0] + self.assertEqual(self.SP_ENDPOINT, address.text) + + def test_prepare_sp_request(self): + assertion = etree.XML(self.ADFS_SECURITY_TOKEN_RESPONSE) + assertion = assertion.xpath( + saml2.V3ADFSPassword.ADFS_ASSERTION_XPATH, + namespaces=saml2.V3ADFSPassword.ADFS_TOKEN_NAMESPACES) + assertion = assertion[0] + assertion = etree.tostring(assertion) + + assertion = assertion.replace( + b'http://docs.oasis-open.org/ws-sx/ws-trust/200512', + b'http://schemas.xmlsoap.org/ws/2005/02/trust') + assertion = urllib.parse.quote(assertion) + assertion = 'wa=wsignin1.0&wresult=' + assertion + + self.adfsplugin.adfs_token = etree.XML( + self.ADFS_SECURITY_TOKEN_RESPONSE) + self.adfsplugin._prepare_sp_request() + + self.assertEqual(assertion, self.adfsplugin.encoded_assertion) + + def test_get_adfs_security_token_authn_fail(self): + """Test proper parsing XML fault after bad authentication. + + An exceptions.AuthorizationFailure should be raised including + error message from the XML message indicating where was the problem. + """ + content = utils.make_oneline(self.ADFS_FAULT) + self.requests_mock.register_uri('POST', + self.IDENTITY_PROVIDER_URL, + content=content, + status_code=500) + + self.adfsplugin._prepare_adfs_request() + self.assertRaises(exceptions.AuthorizationFailure, + self.adfsplugin._get_adfs_security_token, + self.session) + # TODO(marek-denis): Python3 tests complain about missing 'message' + # attributes + # self.assertEqual('a:FailedAuthentication', e.message) + + def test_get_adfs_security_token_bad_response(self): + """Test proper handling HTTP 500 and mangled (non XML) response. + + This should never happen yet, keystoneauth1 should be prepared + and correctly raise exceptions.InternalServerError once it cannot + parse XML fault message + """ + self.requests_mock.register_uri('POST', + self.IDENTITY_PROVIDER_URL, + content=b'NOT XML', + status_code=500) + self.adfsplugin._prepare_adfs_request() + self.assertRaises(exceptions.InternalServerError, + self.adfsplugin._get_adfs_security_token, + self.session) + + # TODO(marek-denis): Need to figure out how to properly send cookies + # from the request_mock methods. + def _send_assertion_to_service_provider(self): + """Test whether SP issues a cookie.""" + cookie = uuid.uuid4().hex + + self.requests_mock.post(self.SP_ENDPOINT, + headers={"set-cookie": cookie}, + status_code=302) + + self.adfsplugin.adfs_token = self._build_adfs_request() + self.adfsplugin._prepare_sp_request() + self.adfsplugin._send_assertion_to_service_provider(self.session) + + self.assertEqual(1, len(self.session.session.cookies)) + + def test_send_assertion_to_service_provider_bad_status(self): + self.requests_mock.register_uri('POST', self.SP_ENDPOINT, + status_code=500) + + self.adfsplugin.adfs_token = etree.XML( + self.ADFS_SECURITY_TOKEN_RESPONSE) + self.adfsplugin._prepare_sp_request() + + self.assertRaises( + exceptions.InternalServerError, + self.adfsplugin._send_assertion_to_service_provider, + self.session) + + def test_access_sp_no_cookies_fail(self): + # clean cookie jar + self.session.session.cookies = [] + + self.assertRaises(exceptions.AuthorizationFailure, + self.adfsplugin._access_service_provider, + self.session) + + def test_check_valid_token_when_authenticated(self): + self.requests_mock.register_uri( + 'GET', self.FEDERATION_AUTH_URL, + json=saml2_fixtures.UNSCOPED_TOKEN, + headers=client_fixtures.AUTH_RESPONSE_HEADERS) + + self.session.session.cookies = [object()] + self.adfsplugin._access_service_provider(self.session) + response = self.adfsplugin.authenticated_response + + self.assertEqual(client_fixtures.AUTH_RESPONSE_HEADERS, + response.headers) + + self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'], + response.json()['token']) + + def test_end_to_end_workflow(self): + self.requests_mock.register_uri( + 'POST', self.IDENTITY_PROVIDER_URL, + content=self.ADFS_SECURITY_TOKEN_RESPONSE, + status_code=200) + self.requests_mock.register_uri( + 'POST', self.SP_ENDPOINT, + headers={"set-cookie": 'x'}, + status_code=302) + self.requests_mock.register_uri( + 'GET', self.FEDERATION_AUTH_URL, + json=saml2_fixtures.UNSCOPED_TOKEN, + headers=client_fixtures.AUTH_RESPONSE_HEADERS) + + # NOTE(marek-denis): We need to mimic this until self.requests_mock can + # issue cookies properly. + self.session.session.cookies = [object()] + token = self.adfsplugin.get_auth_ref(self.session) + self.assertEqual(client_fixtures.AUTH_SUBJECT_TOKEN, token.auth_token) diff --git a/keystoneauth1/tests/unit/extras/saml2/test_identity_v3_saml2.py b/keystoneauth1/tests/unit/extras/saml2/test_auth_saml2.py similarity index 51% rename from keystoneauth1/tests/unit/extras/saml2/test_identity_v3_saml2.py rename to keystoneauth1/tests/unit/extras/saml2/test_auth_saml2.py index bbdc440a..e725e998 100644 --- a/keystoneauth1/tests/unit/extras/saml2/test_identity_v3_saml2.py +++ b/keystoneauth1/tests/unit/extras/saml2/test_auth_saml2.py @@ -10,39 +10,17 @@ # License for the specific language governing permissions and limitations # under the License. -import os import uuid from lxml import etree -from six.moves import urllib from keystoneauth1 import exceptions from keystoneauth1.extras import _saml2 as saml2 -from keystoneauth1 import session -from keystoneauth1.tests.unit import client_fixtures from keystoneauth1.tests.unit.extras.saml2 import fixtures as saml2_fixtures -from keystoneauth1.tests.unit import utils - -ROOTDIR = os.path.dirname(os.path.abspath(__file__)) -XMLDIR = os.path.join(ROOTDIR, 'examples', 'xml/') +from keystoneauth1.tests.unit.extras.saml2 import utils -def make_oneline(s): - return etree.tostring(etree.XML(s)).replace(b'\n', b'') - - -def _load_xml(filename): - with open(XMLDIR + filename, 'rb') as f: - return make_oneline(f.read()) - - -class PluginMixin(object): - - TEST_URL = 'https://keystone:5000/v3' - session = session.Session() - - -class AuthenticateviaSAML2Tests(utils.TestCase, PluginMixin): +class AuthenticateviaSAML2Tests(utils.TestCase): GROUP = 'auth' TEST_TOKEN = uuid.uuid4().hex @@ -90,15 +68,15 @@ class AuthenticateviaSAML2Tests(utils.TestCase, PluginMixin): """Test initial call, expect SOAP message.""" self.requests_mock.get( self.FEDERATION_AUTH_URL, - content=make_oneline(saml2_fixtures.SP_SOAP_RESPONSE)) + content=utils.make_oneline(saml2_fixtures.SP_SOAP_RESPONSE)) a = self.saml2plugin._send_service_provider_request(self.session) self.assertFalse(a) - fixture_soap_response = make_oneline( + fixture_soap_response = utils.make_oneline( saml2_fixtures.SP_SOAP_RESPONSE) - sp_soap_response = make_oneline( + sp_soap_response = utils.make_oneline( etree.tostring(self.saml2plugin.saml2_authn_request)) error_msg = "Expected %s instead of %s" % (fixture_soap_response, @@ -158,10 +136,10 @@ class AuthenticateviaSAML2Tests(utils.TestCase, PluginMixin): saml2_fixtures.SP_SOAP_RESPONSE) self.saml2plugin._send_idp_saml2_authn_request(self.session) - idp_response = make_oneline(etree.tostring( + idp_response = utils.make_oneline(etree.tostring( self.saml2plugin.saml2_idp_authn_response)) - saml2_assertion_oneline = make_oneline( + saml2_assertion_oneline = utils.make_oneline( saml2_fixtures.SAML2_ASSERTION) error = "Expected %s instead of %s" % (saml2_fixtures.SAML2_ASSERTION, idp_response) @@ -277,7 +255,7 @@ class AuthenticateviaSAML2Tests(utils.TestCase, PluginMixin): def test_end_to_end_workflow(self): self.requests_mock.get( self.FEDERATION_AUTH_URL, - content=make_oneline(saml2_fixtures.SP_SOAP_RESPONSE)) + content=utils.make_oneline(saml2_fixtures.SP_SOAP_RESPONSE)) self.requests_mock.post(self.IDENTITY_PROVIDER_URL, content=saml2_fixtures.SAML2_ASSERTION) @@ -292,229 +270,3 @@ class AuthenticateviaSAML2Tests(utils.TestCase, PluginMixin): response = self.saml2plugin.get_auth_ref(self.session) self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN_HEADER, response.auth_token) - - -class AuthenticateviaADFSTests(utils.TestCase, PluginMixin): - - GROUP = 'auth' - - NAMESPACES = { - 's': 'http://www.w3.org/2003/05/soap-envelope', - 'trust': 'http://docs.oasis-open.org/ws-sx/ws-trust/200512', - 'wsa': 'http://www.w3.org/2005/08/addressing', - 'wsp': 'http://schemas.xmlsoap.org/ws/2004/09/policy', - 'a': 'http://www.w3.org/2005/08/addressing', - 'o': ('http://docs.oasis-open.org/wss/2004/01/oasis' - '-200401-wss-wssecurity-secext-1.0.xsd') - } - - USER_XPATH = ('/s:Envelope/s:Header' - '/o:Security' - '/o:UsernameToken' - '/o:Username') - PASSWORD_XPATH = ('/s:Envelope/s:Header' - '/o:Security' - '/o:UsernameToken' - '/o:Password') - ADDRESS_XPATH = ('/s:Envelope/s:Body' - '/trust:RequestSecurityToken' - '/wsp:AppliesTo/wsa:EndpointReference' - '/wsa:Address') - TO_XPATH = ('/s:Envelope/s:Header' - '/a:To') - - TEST_TOKEN = uuid.uuid4().hex - - PROTOCOL = 'saml2' - - @property - def _uuid4(self): - return '4b911420-4982-4009-8afc-5c596cd487f5' - - def setUp(self): - super(AuthenticateviaADFSTests, self).setUp() - - self.IDENTITY_PROVIDER = 'adfs' - self.IDENTITY_PROVIDER_URL = ('http://adfs.local/adfs/service/trust/13' - '/usernamemixed') - self.FEDERATION_AUTH_URL = '%s/%s' % ( - self.TEST_URL, - 'OS-FEDERATION/identity_providers/adfs/protocols/saml2/auth') - self.SP_ENDPOINT = 'https://openstack4.local/Shibboleth.sso/ADFS' - - self.adfsplugin = saml2.V3ADFSPassword( - self.TEST_URL, self.IDENTITY_PROVIDER, - self.IDENTITY_PROVIDER_URL, self.SP_ENDPOINT, - self.TEST_USER, self.TEST_TOKEN, self.PROTOCOL) - - self.ADFS_SECURITY_TOKEN_RESPONSE = _load_xml( - 'ADFS_RequestSecurityTokenResponse.xml') - self.ADFS_FAULT = _load_xml('ADFS_fault.xml') - - def test_get_adfs_security_token(self): - """Test ADFSPassword._get_adfs_security_token().""" - - self.requests_mock.post( - self.IDENTITY_PROVIDER_URL, - content=make_oneline(self.ADFS_SECURITY_TOKEN_RESPONSE), - status_code=200) - - self.adfsplugin._prepare_adfs_request() - self.adfsplugin._get_adfs_security_token(self.session) - - adfs_response = etree.tostring(self.adfsplugin.adfs_token) - fixture_response = self.ADFS_SECURITY_TOKEN_RESPONSE - - self.assertEqual(fixture_response, adfs_response) - - def test_adfs_request_user(self): - self.adfsplugin._prepare_adfs_request() - user = self.adfsplugin.prepared_request.xpath( - self.USER_XPATH, namespaces=self.NAMESPACES)[0] - self.assertEqual(self.TEST_USER, user.text) - - def test_adfs_request_password(self): - self.adfsplugin._prepare_adfs_request() - password = self.adfsplugin.prepared_request.xpath( - self.PASSWORD_XPATH, namespaces=self.NAMESPACES)[0] - self.assertEqual(self.TEST_TOKEN, password.text) - - def test_adfs_request_to(self): - self.adfsplugin._prepare_adfs_request() - to = self.adfsplugin.prepared_request.xpath( - self.TO_XPATH, namespaces=self.NAMESPACES)[0] - self.assertEqual(self.IDENTITY_PROVIDER_URL, to.text) - - def test_prepare_adfs_request_address(self): - self.adfsplugin._prepare_adfs_request() - address = self.adfsplugin.prepared_request.xpath( - self.ADDRESS_XPATH, namespaces=self.NAMESPACES)[0] - self.assertEqual(self.SP_ENDPOINT, address.text) - - def test_prepare_sp_request(self): - assertion = etree.XML(self.ADFS_SECURITY_TOKEN_RESPONSE) - assertion = assertion.xpath( - saml2.V3ADFSPassword.ADFS_ASSERTION_XPATH, - namespaces=saml2.V3ADFSPassword.ADFS_TOKEN_NAMESPACES) - assertion = assertion[0] - assertion = etree.tostring(assertion) - - assertion = assertion.replace( - b'http://docs.oasis-open.org/ws-sx/ws-trust/200512', - b'http://schemas.xmlsoap.org/ws/2005/02/trust') - assertion = urllib.parse.quote(assertion) - assertion = 'wa=wsignin1.0&wresult=' + assertion - - self.adfsplugin.adfs_token = etree.XML( - self.ADFS_SECURITY_TOKEN_RESPONSE) - self.adfsplugin._prepare_sp_request() - - self.assertEqual(assertion, self.adfsplugin.encoded_assertion) - - def test_get_adfs_security_token_authn_fail(self): - """Test proper parsing XML fault after bad authentication. - - An exceptions.AuthorizationFailure should be raised including - error message from the XML message indicating where was the problem. - """ - self.requests_mock.register_uri('POST', - self.IDENTITY_PROVIDER_URL, - content=make_oneline(self.ADFS_FAULT), - status_code=500) - - self.adfsplugin._prepare_adfs_request() - self.assertRaises(exceptions.AuthorizationFailure, - self.adfsplugin._get_adfs_security_token, - self.session) - # TODO(marek-denis): Python3 tests complain about missing 'message' - # attributes - # self.assertEqual('a:FailedAuthentication', e.message) - - def test_get_adfs_security_token_bad_response(self): - """Test proper handling HTTP 500 and mangled (non XML) response. - - This should never happen yet, keystoneauth1 should be prepared - and correctly raise exceptions.InternalServerError once it cannot - parse XML fault message - """ - self.requests_mock.register_uri('POST', - self.IDENTITY_PROVIDER_URL, - content=b'NOT XML', - status_code=500) - self.adfsplugin._prepare_adfs_request() - self.assertRaises(exceptions.InternalServerError, - self.adfsplugin._get_adfs_security_token, - self.session) - - # TODO(marek-denis): Need to figure out how to properly send cookies - # from the request_mock methods. - def _send_assertion_to_service_provider(self): - """Test whether SP issues a cookie.""" - cookie = uuid.uuid4().hex - - self.requests_mock.post(self.SP_ENDPOINT, - headers={"set-cookie": cookie}, - status_code=302) - - self.adfsplugin.adfs_token = self._build_adfs_request() - self.adfsplugin._prepare_sp_request() - self.adfsplugin._send_assertion_to_service_provider(self.session) - - self.assertEqual(1, len(self.session.session.cookies)) - - def test_send_assertion_to_service_provider_bad_status(self): - self.requests_mock.register_uri('POST', self.SP_ENDPOINT, - status_code=500) - - self.adfsplugin.adfs_token = etree.XML( - self.ADFS_SECURITY_TOKEN_RESPONSE) - self.adfsplugin._prepare_sp_request() - - self.assertRaises( - exceptions.InternalServerError, - self.adfsplugin._send_assertion_to_service_provider, - self.session) - - def test_access_sp_no_cookies_fail(self): - # clean cookie jar - self.session.session.cookies = [] - - self.assertRaises(exceptions.AuthorizationFailure, - self.adfsplugin._access_service_provider, - self.session) - - def test_check_valid_token_when_authenticated(self): - self.requests_mock.register_uri( - 'GET', self.FEDERATION_AUTH_URL, - json=saml2_fixtures.UNSCOPED_TOKEN, - headers=client_fixtures.AUTH_RESPONSE_HEADERS) - - self.session.session.cookies = [object()] - self.adfsplugin._access_service_provider(self.session) - response = self.adfsplugin.authenticated_response - - self.assertEqual(client_fixtures.AUTH_RESPONSE_HEADERS, - response.headers) - - self.assertEqual(saml2_fixtures.UNSCOPED_TOKEN['token'], - response.json()['token']) - - def test_end_to_end_workflow(self): - self.requests_mock.register_uri( - 'POST', self.IDENTITY_PROVIDER_URL, - content=self.ADFS_SECURITY_TOKEN_RESPONSE, - status_code=200) - self.requests_mock.register_uri( - 'POST', self.SP_ENDPOINT, - headers={"set-cookie": 'x'}, - status_code=302) - self.requests_mock.register_uri( - 'GET', self.FEDERATION_AUTH_URL, - json=saml2_fixtures.UNSCOPED_TOKEN, - headers=client_fixtures.AUTH_RESPONSE_HEADERS) - - # NOTE(marek-denis): We need to mimic this until self.requests_mock can - # issue cookies properly. - self.session.session.cookies = [object()] - token = self.adfsplugin.get_auth_ref(self.session) - self.assertEqual(client_fixtures.AUTH_SUBJECT_TOKEN, token.auth_token) diff --git a/keystoneauth1/tests/unit/extras/saml2/utils.py b/keystoneauth1/tests/unit/extras/saml2/utils.py new file mode 100644 index 00000000..54dbb9c6 --- /dev/null +++ b/keystoneauth1/tests/unit/extras/saml2/utils.py @@ -0,0 +1,39 @@ +# 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 os + +from lxml import etree + +from keystoneauth1 import session +from keystoneauth1.tests.unit import utils + +ROOTDIR = os.path.dirname(os.path.abspath(__file__)) +XMLDIR = os.path.join(ROOTDIR, 'examples', 'xml/') + + +def make_oneline(s): + return etree.tostring(etree.XML(s)).replace(b'\n', b'') + + +def _load_xml(filename): + with open(XMLDIR + filename, 'rb') as f: + return make_oneline(f.read()) + + +class TestCase(utils.TestCase): + + TEST_URL = 'https://keystone:5000/v3' + + def setUp(self): + super(TestCase, self).setUp() + self.session = session.Session()