Add WebSSO support for federation
Add the ability to return a templated post back HTML response when a call is made that contains info from trusted dashboard host. implements bp websso-portal Co-Authored-By: Jose Castro Leon <jose.castro.leon@cern.ch> Co-Authored-By: Marek Denis <marek.denis@cern.ch> Co-Authored-By: Thai Tran <tqtran@us.ibm.com> Change-Id: Ia5a5c9bd176ea0372c92de6d0a3587d82f2fe5d7
This commit is contained in:
parent
ce701f2771
commit
cd92123a4c
|
@ -0,0 +1,22 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
||||||
|
<head>
|
||||||
|
<title>Keystone WebSSO redirect</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<form id="sso" name="sso" action="$host" method="post">
|
||||||
|
Please wait...
|
||||||
|
<br/>
|
||||||
|
<input type="hidden" name="token" id="token" value="$token"/>
|
||||||
|
<noscript>
|
||||||
|
<input type="submit" name="submit_no_javascript" id="submit_no_javascript"
|
||||||
|
value="If your JavaScript is disabled, please click to continue"/>
|
||||||
|
</noscript>
|
||||||
|
</form>
|
||||||
|
<script type="text/javascript">
|
||||||
|
window.onload = function() {
|
||||||
|
document.forms['sso'].submit();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -19,6 +19,7 @@ import oslo_messaging
|
||||||
_DEFAULT_AUTH_METHODS = ['external', 'password', 'token']
|
_DEFAULT_AUTH_METHODS = ['external', 'password', 'token']
|
||||||
_CERTFILE = '/etc/keystone/ssl/certs/signing_cert.pem'
|
_CERTFILE = '/etc/keystone/ssl/certs/signing_cert.pem'
|
||||||
_KEYFILE = '/etc/keystone/ssl/private/signing_key.pem'
|
_KEYFILE = '/etc/keystone/ssl/private/signing_key.pem'
|
||||||
|
_SSO_CALLBACK = '/etc/keystone/sso_callback_template.html'
|
||||||
|
|
||||||
|
|
||||||
FILE_OPTIONS = {
|
FILE_OPTIONS = {
|
||||||
|
@ -520,6 +521,17 @@ FILE_OPTIONS = {
|
||||||
'unless you really have to. Changing this option '
|
'unless you really have to. Changing this option '
|
||||||
'to empty string or None will not have any impact and '
|
'to empty string or None will not have any impact and '
|
||||||
'default name will be used.'),
|
'default name will be used.'),
|
||||||
|
cfg.MultiStrOpt('trusted_dashboard', default=[],
|
||||||
|
help='A list of trusted dashboard hosts. Before '
|
||||||
|
'accepting a Single Sign-On request to return a '
|
||||||
|
'token, the origin host must be a member of the '
|
||||||
|
'trusted_dashboard list. This configuration '
|
||||||
|
'option may be repeated for multiple values. '
|
||||||
|
'For example: trusted_dashboard=http://acme.com '
|
||||||
|
'trusted_dashboard=http://beta.com'),
|
||||||
|
cfg.StrOpt('sso_callback_template', default=_SSO_CALLBACK,
|
||||||
|
help='Location of Single Sign-On callback handler, will '
|
||||||
|
'return a token to a trusted dashboard host.'),
|
||||||
],
|
],
|
||||||
'policy': [
|
'policy': [
|
||||||
cfg.StrOpt('driver',
|
cfg.StrOpt('driver',
|
||||||
|
|
|
@ -13,6 +13,11 @@
|
||||||
"""Extensions supporting Federation."""
|
"""Extensions supporting Federation."""
|
||||||
|
|
||||||
from oslo_config import cfg
|
from oslo_config import cfg
|
||||||
|
import string
|
||||||
|
|
||||||
|
from oslo_log import log
|
||||||
|
from six.moves import urllib
|
||||||
|
import webob
|
||||||
|
|
||||||
from keystone.auth import controllers as auth_controllers
|
from keystone.auth import controllers as auth_controllers
|
||||||
from keystone.common import authorization
|
from keystone.common import authorization
|
||||||
|
@ -29,6 +34,7 @@ from keystone.models import token_model
|
||||||
|
|
||||||
|
|
||||||
CONF = cfg.CONF
|
CONF = cfg.CONF
|
||||||
|
LOG = log.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class _ControllerBase(controller.V3Controller):
|
class _ControllerBase(controller.V3Controller):
|
||||||
|
@ -259,6 +265,47 @@ class Auth(auth_controllers.Auth):
|
||||||
|
|
||||||
return self.authenticate_for_token(context, auth=auth)
|
return self.authenticate_for_token(context, auth=auth)
|
||||||
|
|
||||||
|
def federated_sso_auth(self, context, protocol_id):
|
||||||
|
try:
|
||||||
|
remote_id_name = CONF.federation.remote_id_attribute
|
||||||
|
identity_provider = context['environment'][remote_id_name]
|
||||||
|
except KeyError:
|
||||||
|
msg = _('Missing entity ID from environment')
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exception.Unauthorized(msg)
|
||||||
|
|
||||||
|
if 'origin' in context['query_string']:
|
||||||
|
origin = context['query_string'].get('origin')
|
||||||
|
host = urllib.parse.unquote_plus(origin)
|
||||||
|
else:
|
||||||
|
msg = _('Request must have an origin query parameter')
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exception.ValidationError(msg)
|
||||||
|
|
||||||
|
if host in CONF.federation.trusted_dashboard:
|
||||||
|
res = self.federated_authentication(context, identity_provider,
|
||||||
|
protocol_id)
|
||||||
|
token_id = res.headers['X-Subject-Token']
|
||||||
|
return self.render_html_response(host, token_id)
|
||||||
|
else:
|
||||||
|
msg = _('%(host)s is not a trusted dashboard host')
|
||||||
|
msg = msg % {'host': host}
|
||||||
|
LOG.error(msg)
|
||||||
|
raise exception.Unauthorized(msg)
|
||||||
|
|
||||||
|
def render_html_response(self, host, token_id):
|
||||||
|
"""Forms an HTML Form from a template with autosubmit."""
|
||||||
|
|
||||||
|
headers = [('Content-Type', 'text/html')]
|
||||||
|
|
||||||
|
with open(CONF.federation.sso_callback_template) as template:
|
||||||
|
src = string.Template(template.read())
|
||||||
|
|
||||||
|
subs = {'host': host, 'token': token_id}
|
||||||
|
body = src.substitute(subs)
|
||||||
|
return webob.Response(body=body, status='200',
|
||||||
|
headerlist=headers)
|
||||||
|
|
||||||
@validation.validated(schema.saml_create, 'auth')
|
@validation.validated(schema.saml_create, 'auth')
|
||||||
def create_saml_assertion(self, context, auth):
|
def create_saml_assertion(self, context, auth):
|
||||||
"""Exchange a scoped token for a SAML assertion.
|
"""Exchange a scoped token for a SAML assertion.
|
||||||
|
|
|
@ -77,6 +77,12 @@ class FederationExtension(wsgi.V3ExtensionRouter):
|
||||||
POST /auth/OS-FEDERATION/saml2
|
POST /auth/OS-FEDERATION/saml2
|
||||||
GET /OS-FEDERATION/saml2/metadata
|
GET /OS-FEDERATION/saml2/metadata
|
||||||
|
|
||||||
|
GET /auth/OS-FEDERATION/websso/{protocol_id}
|
||||||
|
?origin=https%3A//horizon.example.com
|
||||||
|
|
||||||
|
POST /auth/OS-FEDERATION/websso/{protocol_id}
|
||||||
|
?origin=https%3A//horizon.example.com
|
||||||
|
|
||||||
"""
|
"""
|
||||||
def _construct_url(self, suffix):
|
def _construct_url(self, suffix):
|
||||||
return "/OS-FEDERATION/%s" % suffix
|
return "/OS-FEDERATION/%s" % suffix
|
||||||
|
@ -207,6 +213,14 @@ class FederationExtension(wsgi.V3ExtensionRouter):
|
||||||
path='/auth' + self._construct_url('saml2'),
|
path='/auth' + self._construct_url('saml2'),
|
||||||
post_action='create_saml_assertion',
|
post_action='create_saml_assertion',
|
||||||
rel=build_resource_relation(resource_name='saml2'))
|
rel=build_resource_relation(resource_name='saml2'))
|
||||||
|
self._add_resource(
|
||||||
|
mapper, auth_controller,
|
||||||
|
path='/auth' + self._construct_url('websso/{protocol_id}'),
|
||||||
|
get_post_action='federated_sso_auth',
|
||||||
|
rel=build_resource_relation(resource_name='websso'),
|
||||||
|
path_vars={
|
||||||
|
'protocol_id': PROTOCOL_ID_PARAMETER_RELATION,
|
||||||
|
})
|
||||||
|
|
||||||
# Keystone-Identity-Provider metadata endpoint
|
# Keystone-Identity-Provider metadata endpoint
|
||||||
self._add_resource(
|
self._add_resource(
|
||||||
|
|
|
@ -24,6 +24,7 @@ from oslotest import mockpatch
|
||||||
import saml2
|
import saml2
|
||||||
from saml2 import saml
|
from saml2 import saml
|
||||||
from saml2 import sigver
|
from saml2 import sigver
|
||||||
|
from six.moves import urllib
|
||||||
import xmldsig
|
import xmldsig
|
||||||
|
|
||||||
from keystone.auth import controllers as auth_controllers
|
from keystone.auth import controllers as auth_controllers
|
||||||
|
@ -32,6 +33,7 @@ from keystone.contrib.federation import idp as keystone_idp
|
||||||
from keystone.contrib.federation import utils as mapping_utils
|
from keystone.contrib.federation import utils as mapping_utils
|
||||||
from keystone import exception
|
from keystone import exception
|
||||||
from keystone import notifications
|
from keystone import notifications
|
||||||
|
from keystone.tests.unit import core
|
||||||
from keystone.tests.unit import federation_fixtures
|
from keystone.tests.unit import federation_fixtures
|
||||||
from keystone.tests.unit import mapping_fixtures
|
from keystone.tests.unit import mapping_fixtures
|
||||||
from keystone.tests.unit import test_v3
|
from keystone.tests.unit import test_v3
|
||||||
|
@ -1937,10 +1939,10 @@ class FederatedTokenTests(FederationTests):
|
||||||
self.tokens['ADMIN_ASSERTION'], 'domain',
|
self.tokens['ADMIN_ASSERTION'], 'domain',
|
||||||
self.domainC['id'])
|
self.domainC['id'])
|
||||||
|
|
||||||
def _inject_assertion(self, context, variant):
|
def _inject_assertion(self, context, variant, query_string=None):
|
||||||
assertion = getattr(mapping_fixtures, variant)
|
assertion = getattr(mapping_fixtures, variant)
|
||||||
context['environment'].update(assertion)
|
context['environment'].update(assertion)
|
||||||
context['query_string'] = []
|
context['query_string'] = query_string or []
|
||||||
|
|
||||||
|
|
||||||
class FederatedTokenTestsMethodToken(FederatedTokenTests):
|
class FederatedTokenTestsMethodToken(FederatedTokenTests):
|
||||||
|
@ -2590,3 +2592,64 @@ class ServiceProviderTests(FederationTests):
|
||||||
def test_delete_service_provider_404(self):
|
def test_delete_service_provider_404(self):
|
||||||
url = self.base_url(suffix=uuid.uuid4().hex)
|
url = self.base_url(suffix=uuid.uuid4().hex)
|
||||||
self.delete(url, expected_status=404)
|
self.delete(url, expected_status=404)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSSOTests(FederatedTokenTests):
|
||||||
|
"""A class for testing Web SSO."""
|
||||||
|
|
||||||
|
SSO_URL = '/auth/OS-FEDERATION/websso/'
|
||||||
|
SSO_TEMPLATE_NAME = 'sso_callback_template.html'
|
||||||
|
SSO_TEMPLATE_PATH = os.path.join(core.dirs.etc(), SSO_TEMPLATE_NAME)
|
||||||
|
TRUSTED_DASHBOARD = 'http://horizon.com'
|
||||||
|
ORIGIN = urllib.parse.quote_plus(TRUSTED_DASHBOARD)
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(WebSSOTests, self).setUp()
|
||||||
|
self.api = federation_controllers.Auth()
|
||||||
|
|
||||||
|
def config_overrides(self):
|
||||||
|
super(WebSSOTests, self).config_overrides()
|
||||||
|
self.config_fixture.config(
|
||||||
|
group='federation',
|
||||||
|
trusted_dashboard=[self.TRUSTED_DASHBOARD],
|
||||||
|
sso_callback_template=self.SSO_TEMPLATE_PATH,
|
||||||
|
remote_id_attribute=self.REMOTE_ID_ATTR)
|
||||||
|
|
||||||
|
def test_render_callback_template(self):
|
||||||
|
token_id = uuid.uuid4().hex
|
||||||
|
resp = self.api.render_html_response(self.TRUSTED_DASHBOARD, token_id)
|
||||||
|
self.assertIn(token_id, resp.body)
|
||||||
|
self.assertIn(self.TRUSTED_DASHBOARD, resp.body)
|
||||||
|
|
||||||
|
def test_federated_sso_auth(self):
|
||||||
|
environment = {self.REMOTE_ID_ATTR: self.IDP}
|
||||||
|
context = {'environment': environment}
|
||||||
|
query_string = {'origin': self.ORIGIN}
|
||||||
|
self._inject_assertion(context, 'EMPLOYEE_ASSERTION', query_string)
|
||||||
|
resp = self.api.federated_sso_auth(context, self.PROTOCOL)
|
||||||
|
self.assertIn(self.TRUSTED_DASHBOARD, resp.body)
|
||||||
|
|
||||||
|
def test_federated_sso_missing_query(self):
|
||||||
|
environment = {self.REMOTE_ID_ATTR: self.IDP}
|
||||||
|
context = {'environment': environment}
|
||||||
|
self._inject_assertion(context, 'EMPLOYEE_ASSERTION')
|
||||||
|
self.assertRaises(exception.ValidationError,
|
||||||
|
self.api.federated_sso_auth,
|
||||||
|
context, self.PROTOCOL)
|
||||||
|
|
||||||
|
def test_federated_sso_untrusted_dashboard(self):
|
||||||
|
environment = {self.REMOTE_ID_ATTR: self.IDP}
|
||||||
|
context = {'environment': environment}
|
||||||
|
query_string = {'origin': uuid.uuid4().hex}
|
||||||
|
self._inject_assertion(context, 'EMPLOYEE_ASSERTION', query_string)
|
||||||
|
self.assertRaises(exception.Unauthorized,
|
||||||
|
self.api.federated_sso_auth,
|
||||||
|
context, self.PROTOCOL)
|
||||||
|
|
||||||
|
def test_federated_sso_missing_remote_id(self):
|
||||||
|
context = {'environment': {}}
|
||||||
|
query_string = {'origin': self.ORIGIN}
|
||||||
|
self._inject_assertion(context, 'EMPLOYEE_ASSERTION', query_string)
|
||||||
|
self.assertRaises(exception.Unauthorized,
|
||||||
|
self.api.federated_sso_auth,
|
||||||
|
context, self.PROTOCOL)
|
||||||
|
|
|
@ -312,6 +312,10 @@ V3_JSON_HOME_RESOURCES_INHERIT_DISABLED = {
|
||||||
json_home.build_v3_resource_relation('users'): {'href': '/users'},
|
json_home.build_v3_resource_relation('users'): {'href': '/users'},
|
||||||
_build_federation_rel(resource_name='domains'): {
|
_build_federation_rel(resource_name='domains'): {
|
||||||
'href': '/OS-FEDERATION/domains'},
|
'href': '/OS-FEDERATION/domains'},
|
||||||
|
_build_federation_rel(resource_name='websso'): {
|
||||||
|
'href-template': '/auth/OS-FEDERATION/websso/{protocol_id}',
|
||||||
|
'href-vars': {
|
||||||
|
'protocol_id': PROTOCOL_ID_PARAM_RELATION, }},
|
||||||
_build_federation_rel(resource_name='projects'): {
|
_build_federation_rel(resource_name='projects'): {
|
||||||
'href': '/OS-FEDERATION/projects'},
|
'href': '/OS-FEDERATION/projects'},
|
||||||
_build_federation_rel(resource_name='saml2'): {
|
_build_federation_rel(resource_name='saml2'): {
|
||||||
|
|
Loading…
Reference in New Issue