Add authentication using openID and SAML

To enable websso, make sure you have your environment configured.
Then add following to Horizon settings:
WEBSSO_ENABLED=True

Also make sure your KEYSTONE is version 3+

Depends on:
https://review.openstack.org/#/c/136177/
https://review.openstack.org/#/c/151842/

Co-Authored-By: Thai Tran <tqtran@us.ibm.com>
Co-Authored-By: Jose Castro Leon <jose.castro.leon@cern.ch>
Co-Authored-By: Marek Denis <marek.denis@cern.ch>
Co-Authored-By: Lin Hua Cheng <os.lcheng@gmail.com>

implements bp federated-identity
Change-Id: Ief74bece750ffe633d4323238cad89bad61496ed
This commit is contained in:
Thai Tran 2015-03-16 12:37:47 -07:00 committed by lin-hua-cheng
parent 4e8b064522
commit 302f422568
10 changed files with 273 additions and 12 deletions

View File

@ -95,10 +95,12 @@ class KeystoneBackend(object):
if unscoped_auth:
break
else:
msg = _('No authentication backend could be determined to '
'handle the provided credentials.')
LOG.warn('No authentication backend could be determined to '
'handle the provided credentials. This is likely a '
'configuration error that should be addressed.')
return None
raise exceptions.KeystoneAuthException(msg)
session = utils.get_session()
keystone_client_class = utils.get_keystone_client().Client
@ -174,13 +176,14 @@ class KeystoneBackend(object):
interface = getattr(settings, 'OPENSTACK_ENDPOINT_TYPE', 'public')
# If we made it here we succeeded. Create our User!
unscoped_token = unscoped_auth_ref.auth_token
user = auth_user.create_user_from_token(
request,
auth_user.Token(scoped_auth_ref),
auth_user.Token(scoped_auth_ref, unscoped_token=unscoped_token),
scoped_auth_ref.service_catalog.url_for(endpoint_type=interface))
if request is not None:
request.session['unscoped_token'] = unscoped_auth_ref.auth_token
request.session['unscoped_token'] = unscoped_token
request.user = user
scoped_client = keystone_client_class(session=session,
auth=scoped_auth)

View File

@ -21,6 +21,7 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.debug import sensitive_variables # noqa
from openstack_auth import exceptions
from openstack_auth import utils
LOG = logging.getLogger(__name__)
@ -72,6 +73,23 @@ class Login(django_auth_forms.AuthenticationForm):
self.fields['region'].initial = self.request.COOKIES.get(
'login_region')
# if websso is enabled and keystone version supported
# prepend the websso_choices select input to the form
if utils.is_websso_enabled():
initial = getattr(settings, 'WEBSSO_INITIAL_CHOICE', 'credentials')
choicefield = forms.ChoiceField(
label=_("Authenticate using"),
choices=getattr(settings, 'WEBSSO_CHOICES', ()),
required=False,
initial=initial)
self.fields.insert(0, 'auth_type', choicefield)
# websso is enabled, but keystone version is not supported
elif getattr(settings, 'WEBSSO_ENABLED', False):
msg = ("Websso is enabled but horizon is not configured to work " +
"with keystone version 3 or above.")
LOG.warning(msg)
@staticmethod
def get_region_choices():
default_region = (settings.OPENSTACK_KEYSTONE_URL, "Default Region")

View File

@ -81,7 +81,10 @@ class BasePlugin(object):
try:
if self.keystone_version >= 3:
client = v3_client.Client(session=session, auth=auth_plugin)
return client.projects.list(user=auth_ref.user_id)
if auth_ref.is_federated:
return client.federation.projects.list()
else:
return client.projects.list(user=auth_ref.user_id)
else:
client = v2_client.Client(session=session, auth=auth_plugin)

View File

@ -244,4 +244,72 @@ def generate_test_data():
'catalog': [keystone_service, nova_service],
}, token=auth_token)
# federated user
federated_scoped_token_dict = {
'token': {
'methods': ['password'],
'expires_at': expiration,
'project': {
'id': project_dict_1['id'],
'name': project_dict_1['name'],
'domain': {
'id': domain_dict['id'],
'name': domain_dict['name']
}
},
'user': {
'id': user_dict['id'],
'name': user_dict['name'],
'domain': {
'id': domain_dict['id'],
'name': domain_dict['name']
},
'OS-FEDERATION': {
'identity_provider': 'ACME',
'protocol': 'OIDC',
'groups': [
{'id': uuid.uuid4().hex},
{'id': uuid.uuid4().hex}
]
}
},
'roles': [role_dict],
'catalog': [keystone_service, nova_service]
}
}
test_data.federated_scoped_access_info = access.AccessInfo.factory(
resp=auth_response,
body=federated_scoped_token_dict
)
federated_unscoped_token_dict = {
'token': {
'methods': ['password'],
'expires_at': expiration,
'user': {
'id': user_dict['id'],
'name': user_dict['name'],
'domain': {
'id': domain_dict['id'],
'name': domain_dict['name']
},
'OS-FEDERATION': {
'identity_provider': 'ACME',
'protocol': 'OIDC',
'groups': [
{'id': uuid.uuid4().hex},
{'id': uuid.uuid4().hex}
]
}
},
'catalog': [keystone_service]
}
}
test_data.federated_unscoped_access_info = access.AccessInfo.factory(
resp=auth_response,
body=federated_unscoped_token_dict
)
return test_data

View File

@ -791,6 +791,89 @@ class OpenStackAuthTestsV3(OpenStackAuthTestsMixin, test.TestCase):
self.assertIsNone(utils._PROJECT_CACHE.get(unscoped.auth_token))
class OpenStackAuthTestsWebSSO(OpenStackAuthTestsMixin, test.TestCase):
def _create_token_auth(self, project_id=None, token=None, url=None):
if not token:
token = self.data.federated_unscoped_access_info.auth_token
if not url:
url = settings.OPENSTACK_KEYSTONE_URL
return auth_v3.Token(auth_url=url,
token=token,
project_id=project_id,
reauthenticate=False)
def _mock_unscoped_client(self, unscoped):
plugin = self._create_token_auth(
None,
token=unscoped.auth_token,
url=settings.OPENSTACK_KEYSTONE_URL)
plugin.get_access(mox.IsA(session.Session)).AndReturn(unscoped)
return self.ks_client_module.Client(session=mox.IsA(session.Session),
auth=plugin)
def _mock_unscoped_federated_list_projects(self, client, projects):
client.federation = self.mox.CreateMockAnything()
client.federation.projects = self.mox.CreateMockAnything()
client.federation.projects.list().AndReturn(projects)
def _mock_unscoped_client_list_projects(self, unscoped, projects):
client = self._mock_unscoped_client(unscoped)
self._mock_unscoped_federated_list_projects(client, projects)
def setUp(self):
super(OpenStackAuthTestsWebSSO, self).setUp()
self.mox = mox.Mox()
self.addCleanup(self.mox.VerifyAll)
self.addCleanup(self.mox.UnsetStubs)
self.data = data_v3.generate_test_data()
self.ks_client_module = client_v3
settings.OPENSTACK_API_VERSIONS['identity'] = 3
settings.OPENSTACK_KEYSTONE_URL = 'http://localhost:5000/v3'
settings.WEBSSO_ENABLED = True
settings.WEBSSO_CHOICES = (
('credentials', 'Keystone Credentials'),
('oidc', 'OpenID Connect'),
('saml2', 'Security Assertion Markup Language')
)
self.mox.StubOutClassWithMocks(token_endpoint, 'Token')
self.mox.StubOutClassWithMocks(auth_v3, 'Token')
self.mox.StubOutClassWithMocks(auth_v3, 'Password')
self.mox.StubOutClassWithMocks(client_v3, 'Client')
def test_login_form(self):
url = reverse('login')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertContains(response, 'credentials')
self.assertContains(response, 'oidc')
self.assertContains(response, 'saml2')
def test_web_sso_login(self):
projects = [self.data.project_one, self.data.project_two]
unscoped = self.data.federated_unscoped_access_info
token = unscoped.auth_token
form_data = {'token': token}
self._mock_unscoped_client_list_projects(unscoped, projects)
self._mock_scoped_client_for_tenant(unscoped, self.data.project_one.id)
self.mox.ReplayAll()
url = reverse('websso')
# POST to the page to log in.
response = self.client.post(url, form_data)
self.assertRedirects(response, settings.LOGIN_REDIRECT_URL)
load_tests = load_tests_apply_scenarios

View File

@ -25,5 +25,6 @@ utils.patch_middleware_get_user()
urlpatterns = patterns(
'',
url(r"", include('openstack_auth.urls')),
url(r"^websso/$", "openstack_auth.views.websso", name='websso'),
url(r"^$", generic.TemplateView.as_view(template_name="auth/blank.html"))
)

View File

@ -27,3 +27,9 @@ urlpatterns = patterns(
url(r'^switch_services_region/(?P<region_name>[^/]+)/$', 'switch_region',
name='switch_services_region')
)
if utils.is_websso_enabled():
urlpatterns += patterns(
'openstack_auth.views',
url(r"^websso/$", "websso", name='websso')
)

View File

@ -53,7 +53,9 @@ def create_user_from_token(request, token, endpoint, services_region=None):
service_catalog=token.serviceCatalog,
roles=token.roles,
endpoint=endpoint,
services_region=svc_region)
services_region=svc_region,
is_federated=token.is_federated,
unscoped_token=token.unscoped_token)
class Token(object):
@ -65,7 +67,7 @@ class Token(object):
Added for maintaining backward compatibility with horizon that expects
Token object in the user object.
"""
def __init__(self, auth_ref):
def __init__(self, auth_ref, unscoped_token=None):
# User-related attributes
user = {}
user['id'] = auth_ref.user_id
@ -76,12 +78,16 @@ class Token(object):
# Token-related attributes
self.id = auth_ref.auth_token
self.unscoped_token = unscoped_token
if len(self.id) > 64:
algorithm = getattr(settings, 'OPENSTACK_TOKEN_HASH_ALGORITHM',
'md5')
hasher = hashlib.new(algorithm)
hasher.update(self.id)
self.id = hasher.hexdigest()
# If the scoped_token is long, then unscoped_token must be too.
hasher.update(self.unscoped_token)
self.unscoped_token = hasher.hexdigest()
self.expires = auth_ref.expires
# Project-related attributes
@ -97,6 +103,9 @@ class Token(object):
domain['name'] = auth_ref.domain_name
self.domain = domain
# Federation-related attributes
self.is_federated = auth_ref.is_federated
if auth_ref.version == 'v2.0':
self.roles = auth_ref['user'].get('roles', [])
else:
@ -167,13 +176,22 @@ class User(models.AnonymousUser):
The id of the Keystone domain scoped for the current user/token.
.. attribute:: is_federated
Whether user is federated Keystone user. (Boolean)
.. attribute:: unscoped_token
Unscoped Keystone token.
"""
def __init__(self, id=None, token=None, user=None, tenant_id=None,
service_catalog=None, tenant_name=None, roles=None,
authorized_tenants=None, endpoint=None, enabled=False,
services_region=None, user_domain_id=None,
user_domain_name=None, domain_id=None, domain_name=None,
project_id=None, project_name=None):
project_id=None, project_name=None,
is_federated=False, unscoped_token=None):
self.id = id
self.pk = id
self.token = token
@ -193,6 +211,11 @@ class User(models.AnonymousUser):
self.endpoint = endpoint
self.enabled = enabled
self._authorized_tenants = authorized_tenants
self.is_federated = is_federated
# Unscoped token is used for listing user's project that works
# for both federated and keystone user.
self.unscoped_token = unscoped_token
# List of variables to be deprecated.
self.tenant_id = self.project_id
@ -277,12 +300,12 @@ class User(models.AnonymousUser):
"""Returns a memoized list of tenants this user may access."""
if self.is_authenticated() and self._authorized_tenants is None:
endpoint = self.endpoint
token = self.token
try:
self._authorized_tenants = utils.get_project_list(
user_id=self.id,
auth_url=endpoint,
token=token.id)
token=self.unscoped_token,
is_federated=self.is_federated)
except (keystone_exceptions.ClientException,
keystone_exceptions.AuthorizationFailure):
LOG.exception('Unable to retrieve project list.')

View File

@ -176,6 +176,13 @@ def get_keystone_client():
return client_v3
def is_websso_enabled():
"""Websso is supported in Keystone version 3."""
websso_enabled = getattr(settings, 'WEBSSO_ENABLED', False)
keystonev3_plus = (get_keystone_version() >= 3)
return websso_enabled and keystonev3_plus
def has_in_url_path(url, sub):
"""Test if the `sub` string is in the `url` path."""
scheme, netloc, path, query, fragment = urlparse.urlsplit(url)
@ -214,7 +221,7 @@ def fix_auth_url_version(auth_url):
return auth_url
def get_token_auth_plugin(auth_url, token, project_id):
def get_token_auth_plugin(auth_url, token, project_id=None):
if get_keystone_version() >= 3:
return v3_auth.Token(auth_url=auth_url,
token=token,
@ -230,6 +237,7 @@ def get_token_auth_plugin(auth_url, token, project_id):
@memoize_by_keyword_arg(_PROJECT_CACHE, ('token', ))
def get_project_list(*args, **kwargs):
is_federated = kwargs.get('is_federated', False)
sess = kwargs.get('session') or get_session()
auth_url = fix_auth_url_version(kwargs['auth_url'])
auth = token_endpoint.Token(auth_url, kwargs['token'])
@ -237,6 +245,8 @@ def get_project_list(*args, **kwargs):
if get_keystone_version() < 3:
projects = client.tenants.list()
elif is_federated:
projects = client.federation.projects.list()
else:
projects = client.projects.list(user=kwargs.get('user_id'))

View File

@ -11,6 +11,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import logging
import re
import time
import django
@ -18,15 +19,18 @@ from django.conf import settings
from django.contrib import auth
from django.contrib.auth.decorators import login_required # noqa
from django.contrib.auth import views as django_auth_views
from django import http as django_http
from django import shortcuts
from django.utils import functional
from django.utils import http
from django.views.decorators.cache import never_cache # noqa
from django.views.decorators.csrf import csrf_exempt # noqa
from django.views.decorators.csrf import csrf_protect # noqa
from django.views.decorators.debug import sensitive_post_parameters # noqa
from keystoneclient.auth import token_endpoint
from keystoneclient import exceptions as keystone_exceptions
from openstack_auth import exceptions
from openstack_auth import forms
# This is historic and is added back in to not break older versions of
# Horizon, fix to Horizon to remove this requirement was committed in
@ -49,6 +53,18 @@ LOG = logging.getLogger(__name__)
@never_cache
def login(request, template_name=None, extra_context=None, **kwargs):
"""Logs a user in using the :class:`~openstack_auth.forms.Login` form."""
# If the user enabled websso and selects default protocol
# from the dropdown, We need to redirect user to the websso url
if request.method == 'POST':
protocol = request.POST.get('auth_type', 'credentials')
if utils.is_websso_enabled() and protocol != 'credentials':
region = request.POST.get('region')
origin = request.build_absolute_uri('/auth/websso/')
url = ('%s/auth/OS-FEDERATION/websso/%s?origin=%s' %
(region, protocol, origin))
return shortcuts.redirect(url)
if not request.is_ajax():
# If the user is already authenticated, redirect them to the
# dashboard straight away, unless the 'next' parameter is set as it
@ -112,6 +128,30 @@ def login(request, template_name=None, extra_context=None, **kwargs):
return res
@sensitive_post_parameters()
@csrf_exempt
@never_cache
def websso(request):
"""Logs a user in using a token from Keystone's POST."""
referer = request.META.get('HTTP_REFERER', settings.OPENSTACK_KEYSTONE_URL)
auth_url = re.sub(r'/auth.*', '', referer)
token = request.POST.get('token')
try:
request.user = auth.authenticate(request=request, auth_url=auth_url,
token=token)
except exceptions.KeystoneAuthException as exc:
msg = 'Login failed: %s' % unicode(exc)
res = django_http.HttpResponseRedirect(settings.LOGIN_URL)
res.set_cookie('logout_reason', msg, max_age=10)
return res
auth_user.set_session_from_user(request, request.user)
auth.login(request, request.user)
if request.session.test_cookie_worked():
request.session.delete_test_cookie()
return django_http.HttpResponseRedirect(settings.LOGIN_REDIRECT_URL)
def logout(request, login_url=None, **kwargs):
"""Logs out the user if he is logged in. Then redirects to the log-in page.
@ -165,8 +205,12 @@ def switch(request, tenant_id, redirect_field_name=auth.REDIRECT_FIELD_NAME):
endpoint = utils.fix_auth_url_version(request.user.endpoint)
session = utils.get_session()
# Keystone can be configured to prevent exchanging a scoped token for
# another token. Always use the unscoped token for requesting a
# scoped token.
unscoped_token = request.user.unscoped_token
auth = utils.get_token_auth_plugin(auth_url=endpoint,
token=request.user.token.id,
token=unscoped_token,
project_id=tenant_id)
try:
@ -193,7 +237,9 @@ def switch(request, tenant_id, redirect_field_name=auth.REDIRECT_FIELD_NAME):
if old_token and old_endpoint and old_token.id != auth_ref.auth_token:
delete_token(endpoint=old_endpoint, token_id=old_token.id)
user = auth_user.create_user_from_token(
request, auth_user.Token(auth_ref), endpoint)
request,
auth_user.Token(auth_ref, unscoped_token=unscoped_token),
endpoint)
auth_user.set_session_from_user(request, user)
response = shortcuts.redirect(redirect_to)
utils.set_response_cookie(response, 'recent_project',