Last active
August 29, 2015 14:17
-
-
Save jeffdeville/529cab309f14d06ae2c8 to your computer and use it in GitHub Desktop.
Keystone SSO
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This was just a branch off of master. We're not using the Federated path, | |
but we are using SSO (it'd be nice if we could make this a separate setting) | |
"""Logs a user in using a token from Keystone's POST.""" | |
referer = request.META.get('HTTP_REFERER') | |
auth_url = re.sub(r'/auth.*', '', referer) | |
- request.federated_login = True | |
+ request.federated_login = False | |
request.user = auth.authenticate(request=request, auth_url=auth_url) | |
auth_user.set_session_from_user(request, request.user) | |
auth.login(request, request.user) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
From 2da7cf44b6f78c28a0b7706b28dc733146810df8 Mon Sep 17 00:00:00 2001 | |
From: Jeff Deville <[email protected]> | |
Date: Thu, 19 Mar 2015 16:40:23 -0400 | |
Subject: [PATCH] * add a new auth mechanism called InferredDomain that will | |
load the user regardless of domain * sso working in keystone using the | |
remote provider * Update the url for the return call to live under the | |
mod_openidc plugin's sphere of influence | |
--- | |
etc/sso_callback_template.html | 22 +++++++ | |
keystone/auth/controllers.py | 44 ++++++++++++-- | |
keystone/auth/plugins/external.py | 32 +++++++++- | |
keystone/auth/routers.py | 6 ++ | |
keystone/common/config.py | 13 ++++- | |
keystone/tests/default_fixtures.py | 25 +++++++- | |
keystone/tests/test_auth_plugin.py | 110 ++++++++++++++++++++++++++++++++++- | |
keystone/tests/test_v3_federation.py | 1 - | |
keystone/tests/test_versions.py | 2 + | |
9 files changed, 245 insertions(+), 10 deletions(-) | |
create mode 100644 etc/sso_callback_template.html | |
diff --git a/etc/sso_callback_template.html b/etc/sso_callback_template.html | |
new file mode 100644 | |
index 0000000..3364d69 | |
--- /dev/null | |
+++ b/etc/sso_callback_template.html | |
@@ -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> | |
diff --git a/keystone/auth/controllers.py b/keystone/auth/controllers.py | |
index 21e4c9b..8a2db50 100644 | |
--- a/keystone/auth/controllers.py | |
+++ b/keystone/auth/controllers.py | |
@@ -13,10 +13,12 @@ | |
# under the License. | |
import sys | |
- | |
+import string | |
from keystoneclient.common import cms | |
from oslo.utils import timeutils | |
import six | |
+from six.moves import urllib | |
+import webob | |
from keystone.assignment import controllers as assignment_controllers | |
from keystone.common import authorization | |
@@ -361,7 +363,38 @@ class Auth(controller.V3Controller): | |
super(Auth, self).__init__(*args, **kw) | |
config.setup_authentication() | |
- def authenticate_for_token(self, context, auth=None): | |
+ def sso_auth(self, context, auth=None): | |
+ 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: | |
+ auth = {'identity': {'methods': []}} | |
+ token_id = self.authenticate_for_token(context, | |
+ auth=auth, | |
+ renderToken=False) | |
+ 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) | |
+ | |
+ def authenticate_for_token(self, context, auth=None, renderToken=True): | |
"""Authenticate user and issue a token.""" | |
include_catalog = 'nocatalog' not in context['query_string'] | |
@@ -397,8 +430,11 @@ class Auth(controller.V3Controller): | |
if trust: | |
self.trust_api.consume_use(trust['id']) | |
- return render_token_data_response(token_id, token_data, | |
- created=True) | |
+ if renderToken: | |
+ return render_token_data_response(token_id, token_data, | |
+ created=True) | |
+ else: | |
+ return token_id | |
except exception.TrustNotFound as e: | |
raise exception.Unauthorized(e) | |
diff --git a/keystone/auth/plugins/external.py b/keystone/auth/plugins/external.py | |
index 3cf51eb..970d11f 100644 | |
--- a/keystone/auth/plugins/external.py | |
+++ b/keystone/auth/plugins/external.py | |
@@ -21,13 +21,15 @@ import six | |
from keystone import auth | |
from keystone.common import config | |
from keystone.common import dependency | |
+from keystone.common import driver_hints | |
+from keystone.openstack.common import log | |
from keystone import exception | |
from keystone.i18n import _ | |
from keystone.openstack.common import versionutils | |
CONF = config.CONF | |
- | |
+LOG = log.getLogger(__name__) | |
@six.add_metaclass(abc.ABCMeta) | |
class Base(auth.AuthMethodHandler): | |
@@ -95,6 +97,34 @@ class Domain(Base): | |
user_ref = self.identity_api.get_user_by_name(username, domain_id) | |
return user_ref | |
[email protected]('assignment_api', 'identity_api') | |
+class InferredDomain(Base): | |
+ def _authenticate(self, remote_user, context): | |
+ if remote_user is None: | |
+ return {} | |
+ | |
+ email = self.__extract_email(remote_user) | |
+ hints = driver_hints.Hints() | |
+ hints.add_filter('name', email) | |
+ users = self.identity_api.list_users(hints=hints) | |
+ | |
+ if len(users) == 1: | |
+ return users[0] | |
+ elif len(users) > 1: | |
+ raise exception.Unauthorized( | |
+ _("Multiple users found with the same username")) | |
+ else: | |
+ return {} | |
+ | |
+ def __extract_email(self, remote_user): | |
+ if remote_user is None: | |
+ return None | |
+ user_bits = remote_user.split('@') | |
+ if len(user_bits) == 3: | |
+ return '%s@%s' % (user_bits[0], user_bits[1]) | |
+ else: | |
+ raise exception.Unauthorized( | |
+ _('Invalid REMOTE_USER Format: %s' % remote_user)) | |
@dependency.requires('assignment_api', 'identity_api') | |
class KerberosDomain(Domain): | |
diff --git a/keystone/auth/routers.py b/keystone/auth/routers.py | |
index 63b4730..6a758cb 100644 | |
--- a/keystone/auth/routers.py | |
+++ b/keystone/auth/routers.py | |
@@ -59,3 +59,9 @@ class Routers(wsgi.RoutersBase): | |
path='/auth/domains', | |
get_action='get_auth_domains', | |
rel=json_home.build_v3_resource_relation('auth_domains')) | |
+ | |
+ self._add_resource( | |
+ mapper, auth_controller, | |
+ path='/auth/OS-FEDERATION/websso/oidc', | |
+ get_post_action='sso_auth', | |
+ rel=json_home.build_v3_resource_relation('sso_auth')) | |
diff --git a/keystone/common/config.py b/keystone/common/config.py | |
index d7f9dd8..cc59f9d 100644 | |
--- a/keystone/common/config.py | |
+++ b/keystone/common/config.py | |
@@ -19,7 +19,7 @@ from oslo import 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 = { | |
None: [ | |
@@ -464,6 +464,17 @@ FILE_OPTIONS = { | |
cfg.StrOpt('assertion_prefix', default='', | |
help='Value to be used when filtering assertion parameters ' | |
'from the environment.'), | |
+ 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/tests/default_fixtures.py b/keystone/tests/default_fixtures.py | |
index fb8ea04..553cb45 100644 | |
--- a/keystone/tests/default_fixtures.py | |
+++ b/keystone/tests/default_fixtures.py | |
@@ -16,6 +16,7 @@ | |
# performance may be negatively affected. | |
DEFAULT_DOMAIN_ID = 'default' | |
+SUNGARD_DOMAIN_ID = 'sungardas.com' | |
TENANTS = [ | |
{ | |
@@ -81,6 +82,22 @@ USERS = [ | |
'enabled': True, | |
'tenants': ['bar'], | |
'email': '[email protected]', | |
+ }, { | |
+ 'id': 'danger_avoid_1', | |
+ 'name': '[email protected]', | |
+ 'domain_id': SUNGARD_DOMAIN_ID, | |
+ 'password': 'snafu', | |
+ 'enabled': True, | |
+ 'tenants': ['bar'], | |
+ 'email': '[email protected]', | |
+ }, { | |
+ 'id': 'danger_avoid_2', | |
+ 'name': '[email protected]', | |
+ 'domain_id': DEFAULT_DOMAIN_ID, | |
+ 'password': 'snafu', | |
+ 'enabled': True, | |
+ 'tenants': ['bar'], | |
+ 'email': '[email protected]', | |
} | |
] | |
@@ -114,4 +131,10 @@ DOMAINS = [{'description': | |
' available on Identity API v2.'), | |
'enabled': True, | |
'id': DEFAULT_DOMAIN_ID, | |
- 'name': u'Default'}] | |
+ 'name': u'Default'}, | |
+ {'description': | |
+ (u'Owns users and tenants (i.e. projects)' | |
+ ' available on Identity API v2.'), | |
+ 'enabled': True, | |
+ 'id': SUNGARD_DOMAIN_ID, | |
+ 'name': u'Sungard'}] | |
diff --git a/keystone/tests/test_auth_plugin.py b/keystone/tests/test_auth_plugin.py | |
index 90a3b9e..3980ca0 100644 | |
--- a/keystone/tests/test_auth_plugin.py | |
+++ b/keystone/tests/test_auth_plugin.py | |
@@ -13,13 +13,16 @@ | |
# under the License. | |
import uuid | |
- | |
import mock | |
+import os | |
+from six.moves import urllib | |
from keystone import auth | |
from keystone import exception | |
from keystone import tests | |
- | |
+from keystone.tests import core | |
+from keystone.tests.ksfixtures import database | |
+from keystone.tests import default_fixtures | |
# for testing purposes only | |
METHOD_NAME = 'simple_challenge_response' | |
@@ -160,6 +163,109 @@ class TestInvalidAuthMethodRegistration(tests.TestCase): | |
self.assertRaises(ValueError, auth.controllers.load_auth_methods) | |
+class TestInferredDomain(tests.TestCase): | |
+ def setUp(self): | |
+ self.useFixture(database.Database()) | |
+ super(TestInferredDomain, self).setUp() | |
+ self.load_backends() | |
+ self.load_fixtures(default_fixtures) | |
+ self.subj = auth.plugins.external.InferredDomain() | |
+ | |
+ # FOO is the username of a member of the default domain | |
+ def test_when_user_is_found_in_default_domain(self): | |
+ found_user = self.subj._authenticate("[email protected]@ssosite.com", None) | |
+ self.assertEqual(found_user['name'], "[email protected]") | |
+ | |
+ def test_when_user_is_not_in_expected_format(self): | |
+ self.assertRaises(exception.Unauthorized, | |
+ self.subj._authenticate, | |
+ "THIS_IS_THE_WRONG_FORMAT", | |
+ None) | |
+ | |
+ def test_when_remote_user_is_none(self): | |
+ found_user = self.subj._authenticate(None, None) | |
+ self.assertEqual(found_user, {}) | |
+ | |
+ # SUNGARD_FOO is the username of a member of a domain that is NOT default | |
+ def test_when_user_is_found_in_different_domain(self): | |
+ found_user = self.subj._authenticate( | |
+ "[email protected]@ssosite.com", None) | |
+ self.assertEqual(found_user['name'], "[email protected]") | |
+ | |
+ def test_when_user_is_missing(self): | |
+ found_user = self.subj._authenticate( | |
+ "[email protected]@ssosite.com", None) | |
+ self.assertEqual(found_user, {}) | |
+ | |
+ | |
+class TestAuthControllersSsoAuth(tests.TestCase): | |
+ 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) | |
+ METHOD_NAME = 'keystone.auth.plugins.external.InferredDomain' | |
+ | |
+ def setUp(self): | |
+ self.useFixture(database.Database()) | |
+ super(TestAuthControllersSsoAuth, self).setUp() | |
+ | |
+ self.load_backends() | |
+ self.load_fixtures(default_fixtures) | |
+ | |
+ self.auth_controller = auth.controllers.Auth() | |
+ self.config_fixture.config( | |
+ group='federation', | |
+ trusted_dashboard=[self.TRUSTED_DASHBOARD], | |
+ sso_callback_template=self.SSO_TEMPLATE_PATH) | |
+ self.config_overrides | |
+ | |
+ def config_overrides(self): | |
+ super(TestAuthControllersSsoAuth, self).config_overrides() | |
+ method_opts = dict( | |
+ [ | |
+ ('external', 'keystone.auth.plugins.external.InferredDomain'), | |
+ ('password', 'keystone.auth.plugins.password.Password'), | |
+ ('token', 'keystone.auth.plugins.token.Token'), | |
+ ]) | |
+ self.auth_plugin_config_override( | |
+ methods=['external', 'password', 'token'], | |
+ **method_opts) | |
+ | |
+ | |
+ def test_render_callback_template(self): | |
+ token_id = uuid.uuid4().hex | |
+ auth_controller = self.auth_controller | |
+ resp = auth_controller.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_missing_query(self): | |
+ context = {'environment': {}, 'query_string': []} | |
+ self.assertRaises(exception.ValidationError, | |
+ self.auth_controller.sso_auth, | |
+ context) | |
+ | |
+ def test_federated_sso_untrusted_dashboard(self): | |
+ context = { | |
+ 'environment': {}, | |
+ 'query_string': {'origin': "I AM NOT TRUSTED"}, | |
+ } | |
+ self.assertRaises(exception.Unauthorized, | |
+ self.auth_controller.sso_auth, | |
+ context) | |
+ | |
+ def test_redirect_from_SSO_login(self): | |
+ context = { | |
+ 'environment': { | |
+ 'REMOTE_USER': "[email protected]" | |
+ }, | |
+ 'query_string': {'origin': self.ORIGIN} | |
+ } | |
+ resp = self.auth_controller.sso_auth(context) | |
+ self.assertIn(self.TRUSTED_DASHBOARD, resp.body) | |
+ | |
+ | |
class TestMapped(tests.TestCase): | |
def setUp(self): | |
super(TestMapped, self).setUp() | |
diff --git a/keystone/tests/test_v3_federation.py b/keystone/tests/test_v3_federation.py | |
index 202e61c..32209ba 100644 | |
--- a/keystone/tests/test_v3_federation.py | |
+++ b/keystone/tests/test_v3_federation.py | |
@@ -38,7 +38,6 @@ from keystone.tests import federation_fixtures | |
from keystone.tests import mapping_fixtures | |
from keystone.tests import test_v3 | |
- | |
CONF = config.CONF | |
LOG = log.getLogger(__name__) | |
ROOTDIR = os.path.dirname(os.path.abspath(__file__)) | |
diff --git a/keystone/tests/test_versions.py b/keystone/tests/test_versions.py | |
index 6954da3..87865cf 100644 | |
--- a/keystone/tests/test_versions.py | |
+++ b/keystone/tests/test_versions.py | |
@@ -132,6 +132,8 @@ V3_JSON_HOME_RESOURCES_INHERIT_DISABLED = { | |
'href': '/auth/projects'}, | |
json_home.build_v3_resource_relation('auth_domains'): { | |
'href': '/auth/domains'}, | |
+ json_home.build_v3_resource_relation('sso_auth'): { | |
+ 'href': '/auth/OS-FEDERATION/websso/oidc'}, | |
json_home.build_v3_resource_relation('credential'): { | |
'href-template': '/credentials/{credential_id}', | |
'href-vars': { | |
-- | |
2.3.2 |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment