Skip to content

Instantly share code, notes, and snippets.

@jeffdeville
Last active August 29, 2015 14:17
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 2 You must be signed in to fork a gist
  • Save jeffdeville/529cab309f14d06ae2c8 to your computer and use it in GitHub Desktop.
Save jeffdeville/529cab309f14d06ae2c8 to your computer and use it in GitHub Desktop.
Keystone SSO
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)
From 2da7cf44b6f78c28a0b7706b28dc733146810df8 Mon Sep 17 00:00:00 2001
From: Jeff Deville <jeffdeville@gmail.com>
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
+@dependency.requires('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': 'sna@snl.coom',
+ }, {
+ 'id': 'danger_avoid_1',
+ 'name': 'sungard@domain.com',
+ 'domain_id': SUNGARD_DOMAIN_ID,
+ 'password': 'snafu',
+ 'enabled': True,
+ 'tenants': ['bar'],
+ 'email': 'sungard@domain.com',
+ }, {
+ 'id': 'danger_avoid_2',
+ 'name': 'foo@domain.com',
+ 'domain_id': DEFAULT_DOMAIN_ID,
+ 'password': 'snafu',
+ 'enabled': True,
+ 'tenants': ['bar'],
+ 'email': 'foo@domain.com',
}
]
@@ -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("foo@domain.com@ssosite.com", None)
+ self.assertEqual(found_user['name'], "foo@domain.com")
+
+ 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(
+ "sungard@domain.com@ssosite.com", None)
+ self.assertEqual(found_user['name'], "sungard@domain.com")
+
+ def test_when_user_is_missing(self):
+ found_user = self.subj._authenticate(
+ "MISSING@domain.com@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': "FOO@ssosite.com"
+ },
+ '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