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'] _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',

View File

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

View File

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

View File

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

View File

@ -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'): {