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:
Steve Martinelli 2014-11-20 18:27:42 -05:00
parent ce701f2771
commit cd92123a4c
6 changed files with 164 additions and 2 deletions

View File

@ -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>

View File

@ -19,6 +19,7 @@ import oslo_messaging
_DEFAULT_AUTH_METHODS = ['external', 'password', 'token']
_CERTFILE = '/etc/keystone/ssl/certs/signing_cert.pem'
_KEYFILE = '/etc/keystone/ssl/private/signing_key.pem'
_SSO_CALLBACK = '/etc/keystone/sso_callback_template.html'
FILE_OPTIONS = {
@ -520,6 +521,17 @@ FILE_OPTIONS = {
'unless you really have to. Changing this option '
'to empty string or None will not have any impact and '
'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': [
cfg.StrOpt('driver',

View File

@ -13,6 +13,11 @@
"""Extensions supporting Federation."""
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.common import authorization
@ -29,6 +34,7 @@ from keystone.models import token_model
CONF = cfg.CONF
LOG = log.getLogger(__name__)
class _ControllerBase(controller.V3Controller):
@ -259,6 +265,47 @@ class Auth(auth_controllers.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')
def create_saml_assertion(self, context, auth):
"""Exchange a scoped token for a SAML assertion.

View File

@ -77,6 +77,12 @@ class FederationExtension(wsgi.V3ExtensionRouter):
POST /auth/OS-FEDERATION/saml2
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):
return "/OS-FEDERATION/%s" % suffix
@ -207,6 +213,14 @@ class FederationExtension(wsgi.V3ExtensionRouter):
path='/auth' + self._construct_url('saml2'),
post_action='create_saml_assertion',
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
self._add_resource(

View File

@ -24,6 +24,7 @@ from oslotest import mockpatch
import saml2
from saml2 import saml
from saml2 import sigver
from six.moves import urllib
import xmldsig
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 import exception
from keystone import notifications
from keystone.tests.unit import core
from keystone.tests.unit import federation_fixtures
from keystone.tests.unit import mapping_fixtures
from keystone.tests.unit import test_v3
@ -1937,10 +1939,10 @@ class FederatedTokenTests(FederationTests):
self.tokens['ADMIN_ASSERTION'], 'domain',
self.domainC['id'])
def _inject_assertion(self, context, variant):
def _inject_assertion(self, context, variant, query_string=None):
assertion = getattr(mapping_fixtures, variant)
context['environment'].update(assertion)
context['query_string'] = []
context['query_string'] = query_string or []
class FederatedTokenTestsMethodToken(FederatedTokenTests):
@ -2590,3 +2592,64 @@ class ServiceProviderTests(FederationTests):
def test_delete_service_provider_404(self):
url = self.base_url(suffix=uuid.uuid4().hex)
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)

View File

@ -312,6 +312,10 @@ V3_JSON_HOME_RESOURCES_INHERIT_DISABLED = {
json_home.build_v3_resource_relation('users'): {'href': '/users'},
_build_federation_rel(resource_name='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'): {
'href': '/OS-FEDERATION/projects'},
_build_federation_rel(resource_name='saml2'): {