diff --git a/etc/sso_callback_template.html b/etc/sso_callback_template.html new file mode 100644 index 0000000000..c6997dc466 --- /dev/null +++ b/etc/sso_callback_template.html @@ -0,0 +1,22 @@ + + + + Keystone WebSSO redirect + + +
+ Please wait... +
+ + +
+ + + \ No newline at end of file diff --git a/keystone/common/config.py b/keystone/common/config.py index b516f23b38..06f9413783 100644 --- a/keystone/common/config.py +++ b/keystone/common/config.py @@ -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', diff --git a/keystone/contrib/federation/controllers.py b/keystone/contrib/federation/controllers.py index dd60e76b40..f4fdefb0d0 100644 --- a/keystone/contrib/federation/controllers.py +++ b/keystone/contrib/federation/controllers.py @@ -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. diff --git a/keystone/contrib/federation/routers.py b/keystone/contrib/federation/routers.py index 97698c0247..49229bdfd6 100644 --- a/keystone/contrib/federation/routers.py +++ b/keystone/contrib/federation/routers.py @@ -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( diff --git a/keystone/tests/unit/test_v3_federation.py b/keystone/tests/unit/test_v3_federation.py index 484698e7fd..6c02d153b8 100644 --- a/keystone/tests/unit/test_v3_federation.py +++ b/keystone/tests/unit/test_v3_federation.py @@ -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) diff --git a/keystone/tests/unit/test_versions.py b/keystone/tests/unit/test_versions.py index 60609d928c..4c526c239f 100644 --- a/keystone/tests/unit/test_versions.py +++ b/keystone/tests/unit/test_versions.py @@ -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'): {