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:
parent
4e8b064522
commit
302f422568
|
@ -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)
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
@ -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"))
|
||||
)
|
||||
|
|
|
@ -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')
|
||||
)
|
||||
|
|
|
@ -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.')
|
||||
|
|
|
@ -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'))
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
Loading…
Reference in New Issue