Merge "SAML2 ECP auth plugin"
This commit is contained in:
0
keystoneclient/contrib/auth/__init__.py
Normal file
0
keystoneclient/contrib/auth/__init__.py
Normal file
0
keystoneclient/contrib/auth/v3/__init__.py
Normal file
0
keystoneclient/contrib/auth/v3/__init__.py
Normal file
411
keystoneclient/contrib/auth/v3/saml2.py
Normal file
411
keystoneclient/contrib/auth/v3/saml2.py
Normal file
@@ -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:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<S:Body>
|
||||
<S:Fault>
|
||||
<faultcode>S:Server</faultcode>
|
||||
<faultstring>responseConsumerURL from SP and
|
||||
assertionConsumerServiceURL from IdP do not match
|
||||
</faultstring>
|
||||
</S:Fault>
|
||||
</S:Body>
|
||||
</S:Envelope>
|
||||
"""
|
||||
|
||||
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://<host>:<port>/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://<host>:<port>/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)
|
121
keystoneclient/tests/v3/saml2_fixtures.py
Normal file
121
keystoneclient/tests/v3/saml2_fixtures.py
Normal file
@@ -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"""<S:Envelope
|
||||
xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<S:Header>
|
||||
<paos:Request xmlns:paos="urn:liberty:paos:2003-08"
|
||||
S:actor="http://schemas.xmlsoap.org/soap/actor/next"
|
||||
S:mustUnderstand="1"
|
||||
responseConsumerURL="https://openstack4.local/Shibboleth.sso/SAML2/ECP"
|
||||
service="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"/>
|
||||
<ecp:Request xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
|
||||
IsPassive="0" S:actor="http://schemas.xmlsoap.org/soap/actor/next"
|
||||
S:mustUnderstand="1">
|
||||
<saml:Issuer xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
||||
https://openstack4.local/shibboleth
|
||||
</saml:Issuer>
|
||||
<samlp:IDPList xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
|
||||
<samlp:IDPEntry ProviderID="https://idp.testshib.org/idp/shibboleth"/>
|
||||
</samlp:IDPList></ecp:Request>
|
||||
<ecp:RelayState xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
|
||||
S:actor="http://schemas.xmlsoap.org/soap/actor/next" S:mustUnderstand="1">
|
||||
ss:mem:6f1f20fafbb38433467e9d477df67615</ecp:RelayState>
|
||||
</S:Header><S:Body><samlp:AuthnRequest
|
||||
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
AssertionConsumerServiceURL="https://openstack4.local/Shibboleth.sso/SAML2/ECP"
|
||||
ID="_a07186e3992e70e92c17b9d249495643" IssueInstant="2014-06-09T09:48:57Z"
|
||||
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:PAOS" Version="2.0">
|
||||
<saml:Issuer
|
||||
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
|
||||
https://openstack4.local/shibboleth
|
||||
</saml:Issuer><samlp:NameIDPolicy AllowCreate="1"/><samlp:Scoping>
|
||||
<samlp:IDPList>
|
||||
<samlp:IDPEntry ProviderID="https://idp.testshib.org/idp/shibboleth"/>
|
||||
</samlp:IDPList></samlp:Scoping></samlp:AuthnRequest></S:Body></S:Envelope>"""
|
||||
|
||||
|
||||
SAML2_ASSERTION = b"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<soap11:Envelope xmlns:soap11="http://schemas.xmlsoap.org/soap/envelope/">
|
||||
<soap11:Header>
|
||||
<ecp:Response xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
|
||||
AssertionConsumerServiceURL="https://openstack4.local/Shibboleth.sso/SAML2/ECP"
|
||||
soap11:actor="http://schemas.xmlsoap.org/soap/actor/next"
|
||||
soap11:mustUnderstand="1"/>
|
||||
<samlec:GeneratedKey xmlns:samlec="urn:ietf:params:xml:ns:samlec"
|
||||
soap11:actor="http://schemas.xmlsoap.org/soap/actor/next">
|
||||
x=
|
||||
</samlec:GeneratedKey>
|
||||
</soap11:Header>
|
||||
<soap11:Body>
|
||||
<saml2p:Response xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
|
||||
Destination="https://openstack4.local/Shibboleth.sso/SAML2/ECP"
|
||||
ID="_bbbe6298d7ee586c915d952013875440"
|
||||
InResponseTo="_a07186e3992e70e92c17b9d249495643"
|
||||
IssueInstant="2014-06-09T09:48:58.945Z" Version="2.0">
|
||||
<saml2:Issuer xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
|
||||
Format="urn:oasis:names:tc:SAML:2.0:nameid-format:entity">
|
||||
https://idp.testshib.org/idp/shibboleth
|
||||
</saml2:Issuer><saml2p:Status>
|
||||
<saml2p:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
|
||||
</saml2p:Status>
|
||||
<saml2:EncryptedAssertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
|
||||
<xenc:EncryptedData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#"
|
||||
Id="_e5215ac77a6028a8da8caa8be89bad44"
|
||||
Type="http://www.w3.org/2001/04/xmlenc#Element">
|
||||
<xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes128-cbc"
|
||||
xmlns:xenc="http://www.w3.org/2001/04/xmlenc#"/>
|
||||
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
||||
<xenc:EncryptedKey Id="_204349856f6e73c9480afc949d1b4643"
|
||||
xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
|
||||
<xenc:EncryptionMethod
|
||||
Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p"
|
||||
xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
|
||||
<ds:DigestMethod Algorithm="http://www.w3.org/2000/09/xmldsig#sha1"
|
||||
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"/>
|
||||
</xenc:EncryptionMethod><ds:KeyInfo><ds:X509Data><ds:X509Certificate>
|
||||
</ds:X509Certificate>
|
||||
</ds:X509Data></ds:KeyInfo>
|
||||
<xenc:CipherData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
|
||||
<xenc:CipherValue>VALUE==</xenc:CipherValue></xenc:CipherData>
|
||||
</xenc:EncryptedKey></ds:KeyInfo>
|
||||
<xenc:CipherData xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
|
||||
<xenc:CipherValue>VALUE=</xenc:CipherValue></xenc:CipherData>
|
||||
</xenc:EncryptedData></saml2:EncryptedAssertion></saml2p:Response>
|
||||
</soap11:Body></soap11:Envelope>
|
||||
"""
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
311
keystoneclient/tests/v3/test_auth_saml2.py
Normal file
311
keystoneclient/tests/v3/test_auth_saml2.py
Normal file
@@ -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)
|
@@ -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
|
||||
|
@@ -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
|
||||
|
Reference in New Issue
Block a user