diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..31d405a --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +*.pyc +*.egg +*.egg-info +.DS_STORE +doc/build +build +dist diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e4ab673 --- /dev/null +++ b/LICENSE @@ -0,0 +1,30 @@ +Copyright (c) 2012, Gabriel Hurley +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + + * Neither the name of the author nor the names of other + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/README.rst b/README.rst new file mode 100644 index 0000000..c9bb756 --- /dev/null +++ b/README.rst @@ -0,0 +1,31 @@ +===================== +Django OpenStack Auth +===================== + +Django OpenStack Auth is a pluggable Django authentication backend that +works with Django's ``contrib.auth`` framework to authenticate a user against +OpenStack's Keystone Identity API. + +The current version is designed to work with the Keystone V2 API. + +Installation +============ + +Installing is quick and easy: + + #. Run ``pip install django_openstack_auth``. + + #. Add ``openstack_auth`` to ``settings.INSTALLED_APPS``. + + #. Add ``'keystone_auth.backend.KeystoneBackend'`` to your + ``settings.AUTHENTICATION_BACKENDS``, e.g.:: + + AUTHENTICATION_BACKENDS = ('keystone_auth.backend.KeystoneBackend',) + + #. Configure your API endpoint(s) in ``settings.py``:: + + OPENSTACK_KEYSTONE_URL = "http://example.com:5000/v2.0" + + #. Include ``'keystone_auth.urls'`` somewhere in your ``urls.py`` file. + + #. Use it as you would any other Django auth backend. diff --git a/openstack_auth/__init__.py b/openstack_auth/__init__.py new file mode 100644 index 0000000..70d73c2 --- /dev/null +++ b/openstack_auth/__init__.py @@ -0,0 +1,2 @@ +# following PEP 386 +__version__ = "1.0" diff --git a/openstack_auth/backend.py b/openstack_auth/backend.py new file mode 100644 index 0000000..4bbec4a --- /dev/null +++ b/openstack_auth/backend.py @@ -0,0 +1,92 @@ +""" Module defining the Django auth backend class for the Keystone API. """ + +import logging + +from django.utils.translation import ugettext as _ + +from keystoneclient.v2_0 import client as keystone_client +from keystoneclient import exceptions as keystone_exceptions +from keystoneclient.v2_0.tokens import Token, TokenManager + +from .exceptions import KeystoneAuthException +from .user import create_user_from_token + + +LOG = logging.getLogger(__name__) + + +KEYSTONE_CLIENT_ATTR = "_keystoneclient" + + +class KeystoneBackend(object): + def get_user(self, user_id): + if user_id == self.request.session["user_id"]: + token = Token(TokenManager(None), + self.request.session['token'], + loaded=True) + endpoint = self.request.session['region_endpoint'] + return create_user_from_token(self.request, token, endpoint) + else: + return None + + def authenticate(self, request=None, username=None, password=None, + tenant=None, auth_url=None): + """ Authenticates a user via the Keystone Identity API. """ + LOG.debug('Beginning user authentication for user "%s".' % username) + + try: + client = keystone_client.Client(username=username, + password=password, + tenant_id=tenant, + auth_url=auth_url) + unscoped_token_data = {"token": client.service_catalog.get_token()} + unscoped_token = Token(TokenManager(None), + unscoped_token_data, + loaded=True) + except keystone_exceptions.Unauthorized: + msg = _('Invalid user name or password.') + raise KeystoneAuthException(msg) + except keystone_exceptions.ClientException: + msg = _("An error occurred authenticating. " + "Please try again later.") + raise KeystoneAuthException(msg) + + # FIXME: Log in to default tenant when the Keystone API returns it... + # For now we list all the user's tenants and iterate through. + try: + tenants = client.tenants.list() + except keystone_exceptions.ClientException: + msg = _('Unable to retrieve authorized projects.') + raise KeystoneAuthException(msg) + + # Abort if there are no tenants for this user + if not tenants: + msg = _('You are not authorized for any projects.') + raise KeystoneAuthException(msg) + + while tenants: + tenant = tenants.pop() + try: + token = client.tokens.authenticate(username=username, + token=unscoped_token.id, + tenant_id=tenant.id) + break + except keystone_exceptions.ClientException: + token = None + + if token is None: + msg = _("Unable to authenticate to any available projects.") + raise KeystoneAuthException(msg) + + # If we made it here we succeeded. Create our User! + user = create_user_from_token(request, token, client.management_url) + + if request is not None: + request.session['unscoped_token'] = unscoped_token.id + request.user = user + + # Support client caching to save on auth calls. + setattr(request, KEYSTONE_CLIENT_ATTR, client) + + LOG.debug('Authentication completed for user "%s".' % username) + return user diff --git a/openstack_auth/exceptions.py b/openstack_auth/exceptions.py new file mode 100644 index 0000000..bc8c539 --- /dev/null +++ b/openstack_auth/exceptions.py @@ -0,0 +1,3 @@ +class KeystoneAuthException(Exception): + """ Generic error class to identify and catch our own errors. """ + pass diff --git a/openstack_auth/forms.py b/openstack_auth/forms.py new file mode 100644 index 0000000..f7a24fe --- /dev/null +++ b/openstack_auth/forms.py @@ -0,0 +1,59 @@ +from django import forms +from django.conf import settings +from django.contrib.auth import authenticate +from django.contrib.auth.forms import AuthenticationForm +from django.utils.translation import ugettext as _ +from django.views.decorators.debug import sensitive_variables + +from .exceptions import KeystoneAuthException + + +class Login(AuthenticationForm): + """ Form used for logging in a user. + + Handles authentication with Keystone, choosing a tenant, and fetching + a scoped token token for that tenant. + """ + region = forms.ChoiceField(label=_("Region"), required=False) + username = forms.CharField(label=_("User Name")) + password = forms.CharField(label=_("Password"), + widget=forms.PasswordInput(render_value=False)) + tenant = forms.CharField(required=False, widget=forms.HiddenInput()) + + def __init__(self, *args, **kwargs): + super(Login, self).__init__(*args, **kwargs) + self.fields['region'].choices = self.get_region_choices() + if len(self.fields['region'].choices) == 1: + self.fields['region'].initial = self.fields['region'].choices[0][0] + self.fields['region'].widget = forms.widgets.HiddenInput() + + @staticmethod + def get_region_choices(): + default_region = (settings.OPENSTACK_KEYSTONE_URL, "Default Region") + return getattr(settings, 'AVAILABLE_REGIONS', [default_region]) + + @sensitive_variables() + def clean(self): + username = self.cleaned_data.get('username') + password = self.cleaned_data.get('password') + region = self.cleaned_data.get('region') + tenant = self.cleaned_data.get('tenant') + + if not tenant: + tenant = None + + if not (username and password): + # Don't authenticate, just let the other validators handle it. + return self.cleaned_data + + try: + self.user_cache = authenticate(request=self.request, + username=username, + password=password, + tenant=tenant, + auth_url=region) + except KeystoneAuthException as exc: + self.request.session.flush() + raise forms.ValidationError(exc) + self.check_for_test_cookie() + return self.cleaned_data diff --git a/openstack_auth/tests/__init__.py b/openstack_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/openstack_auth/tests/data.py b/openstack_auth/tests/data.py new file mode 100644 index 0000000..2b6a480 --- /dev/null +++ b/openstack_auth/tests/data.py @@ -0,0 +1,112 @@ +import uuid + +from datetime import timedelta + +from django.utils import datetime_safe + +from keystoneclient.v2_0.roles import Role, RoleManager +from keystoneclient.v2_0.tenants import Tenant, TenantManager +from keystoneclient.v2_0.tokens import Token, TokenManager +from keystoneclient.v2_0.users import User, UserManager +from keystoneclient.service_catalog import ServiceCatalog + + +class TestDataContainer(object): + """ Arbitrary holder for test data in an object-oriented fashion. """ + pass + + +def generate_test_data(): + ''' Builds a set of test_data data as returned by Keystone. ''' + test_data = TestDataContainer() + + keystone_service = { + 'type': 'identity', + 'name': 'keystone', + 'endpoints_links': [], + 'endpoints': [ + { + 'region': 'RegionOne', + 'adminURL': 'http://admin.localhost:35357/v2.0', + 'internalURL': 'http://internal.localhost:5000/v2.0', + 'publicURL': 'http://public.localhost:5000/v2.0' + } + ] + } + + # Users + user_dict = {'id': uuid.uuid4().hex, + 'name': 'gabriel', + 'email': 'gabriel@example.com', + 'password': 'swordfish', + 'token': '', + 'enabled': True} + test_data.user = User(UserManager(None), user_dict, loaded=True) + + # Tenants + tenant_dict_1 = {'id': uuid.uuid4().hex, + 'name': 'tenant_one', + 'description': '', + 'enabled': True} + tenant_dict_2 = {'id': uuid.uuid4().hex, + 'name': '', + 'description': '', + 'enabled': False} + test_data.tenant_one = Tenant(TenantManager(None), + tenant_dict_1, + loaded=True) + test_data.tenant_two = Tenant(TenantManager(None), + tenant_dict_2, + loaded=True) + + # Roles + role_dict = {'id': uuid.uuid4().hex, + 'name': 'Member'} + test_data.role = Role(RoleManager, role_dict) + + # Tokens + tomorrow = datetime_safe.datetime.now() + timedelta(days=1) + expiration = datetime_safe.datetime.isoformat(tomorrow) + + scoped_token_dict = { + 'token': { + 'id': uuid.uuid4().hex, + 'expires': expiration, + 'tenant': tenant_dict_1, + 'tenants': [tenant_dict_1, tenant_dict_2]}, + 'user': { + 'id': user_dict['id'], + 'name': user_dict['name'], + 'roles': [role_dict]}, + 'serviceCatalog': [keystone_service] + } + test_data.scoped_token = Token(TokenManager(None), + scoped_token_dict, + loaded=True) + + unscoped_token_dict = { + 'token': { + 'id': uuid.uuid4().hex, + 'expires': expiration}, + 'user': { + 'id': user_dict['id'], + 'name': user_dict['name'], + 'roles': [role_dict]}, + 'serviceCatalog': [keystone_service] + } + test_data.unscoped_token = Token(TokenManager(None), + unscoped_token_dict, + loaded=True) + + # Service Catalog + test_data.service_catalog = ServiceCatalog({ + 'serviceCatalog': [keystone_service], + 'token': { + 'id': scoped_token_dict['token']['id'], + 'expires': scoped_token_dict['token']['expires'], + 'user_id': user_dict['id'], + 'tenant_id': tenant_dict_1['id'] + } + }) + + return test_data diff --git a/openstack_auth/tests/models.py b/openstack_auth/tests/models.py new file mode 100644 index 0000000..e69de29 diff --git a/openstack_auth/tests/run_tests.py b/openstack_auth/tests/run_tests.py new file mode 100644 index 0000000..4f27c5a --- /dev/null +++ b/openstack_auth/tests/run_tests.py @@ -0,0 +1,47 @@ +#!/usr/bin/env python +import os +import sys + +from django.conf import settings + +if not settings.configured: + settings.configure( + DATABASES={'default': {'ENGINE': 'django.db.backends.sqlite3'}}, + INSTALLED_APPS=[ + 'django', + 'django.contrib.contenttypes', + 'django.contrib.auth', + 'django.contrib.sessions', + 'openstack_auth', + 'openstack_auth.tests' + ], + MIDDLEWARE_CLASSES=[ + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware' + ], + AUTHENTICATION_BACKENDS=['openstack_auth.backend.KeystoneBackend'], + OPENSTACK_KEYSTONE_URL="http://localhost:5000/v2.0", + ROOT_URLCONF='openstack_auth.tests.urls', + LOGIN_REDIRECT_URL='/' + ) + +from django.test.simple import DjangoTestSuiteRunner + + +def run(*test_args): + if not test_args: + test_args = ['tests'] + parent = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + "..", + "..", + ) + sys.path.insert(0, parent) + failures = DjangoTestSuiteRunner().run_tests(test_args) + sys.exit(failures) + + +if __name__ == '__main__': + run(*sys.argv[1:]) diff --git a/openstack_auth/tests/templates/auth/blank.html b/openstack_auth/tests/templates/auth/blank.html new file mode 100644 index 0000000..e69de29 diff --git a/openstack_auth/tests/templates/auth/login.html b/openstack_auth/tests/templates/auth/login.html new file mode 100644 index 0000000..eac9a9c --- /dev/null +++ b/openstack_auth/tests/templates/auth/login.html @@ -0,0 +1,11 @@ + + + + Login + + +
{{ csrf_token }} + {{ form.as_p }} +
+ + \ No newline at end of file diff --git a/openstack_auth/tests/tests.py b/openstack_auth/tests/tests.py new file mode 100644 index 0000000..dc2c289 --- /dev/null +++ b/openstack_auth/tests/tests.py @@ -0,0 +1,202 @@ +from django import test +from django.conf import settings +from django.core.urlresolvers import reverse + +from keystoneclient import exceptions as keystone_exceptions +from keystoneclient.v2_0 import client + +import mox + +from .data import generate_test_data + + +class OpenStackAuthTests(test.TestCase): + def setUp(self): + super(OpenStackAuthTests, self).setUp() + self.mox = mox.Mox() + self.data = generate_test_data() + endpoint = settings.OPENSTACK_KEYSTONE_URL + self.keystone_client = client.Client(endpoint=endpoint) + self.keystone_client.service_catalog = self.data.service_catalog + + def tearDown(self): + self.mox.UnsetStubs() + self.mox.VerifyAll() + + def test_login(self): + tenants = [self.data.tenant_one, self.data.tenant_two] + user = self.data.user + sc = self.data.service_catalog + + form_data = {'region': settings.OPENSTACK_KEYSTONE_URL, + 'password': user.password, + 'username': user.name} + + self.mox.StubOutWithMock(client, "Client") + self.mox.StubOutWithMock(self.keystone_client.tenants, "list") + self.mox.StubOutWithMock(self.keystone_client.tokens, "authenticate") + + client.Client(auth_url=settings.OPENSTACK_KEYSTONE_URL, + password=user.password, + username=user.name, + tenant_id=None).AndReturn(self.keystone_client) + self.keystone_client.tenants.list().AndReturn(tenants) + self.keystone_client.tokens.authenticate(tenant_id=tenants[1].id, + token=sc.get_token()['id'], + username=user.name) \ + .AndReturn(self.data.scoped_token) + + self.mox.ReplayAll() + + url = reverse('login') + + # GET the page to set the test cookie. + response = self.client.get(url, form_data) + self.assertEqual(response.status_code, 200) + + # POST to the page to log in. + response = self.client.post(url, form_data) + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) + + def test_no_tenants(self): + user = self.data.user + + form_data = {'region': settings.OPENSTACK_KEYSTONE_URL, + 'password': user.password, + 'username': user.name} + + self.mox.StubOutWithMock(client, "Client") + self.mox.StubOutWithMock(self.keystone_client.tenants, "list") + + client.Client(auth_url=settings.OPENSTACK_KEYSTONE_URL, + password=user.password, + username=user.name, + tenant_id=None).AndReturn(self.keystone_client) + self.keystone_client.tenants.list().AndReturn([]) + + self.mox.ReplayAll() + + url = reverse('login') + + # GET the page to set the test cookie. + response = self.client.get(url, form_data) + self.assertEqual(response.status_code, 200) + + # POST to the page to log in. + response = self.client.post(url, form_data) + self.assertTemplateUsed(response, 'auth/login.html') + self.assertContains(response, + 'You are not authorized for any projects.') + + def test_invalid_credentials(self): + user = self.data.user + + form_data = {'region': settings.OPENSTACK_KEYSTONE_URL, + 'password': "invalid", + 'username': user.name} + + self.mox.StubOutWithMock(client, "Client") + + exc = keystone_exceptions.Unauthorized(401) + client.Client(auth_url=settings.OPENSTACK_KEYSTONE_URL, + password="invalid", + username=user.name, + tenant_id=None).AndRaise(exc) + + self.mox.ReplayAll() + + url = reverse('login') + + # GET the page to set the test cookie. + response = self.client.get(url, form_data) + self.assertEqual(response.status_code, 200) + + # POST to the page to log in. + response = self.client.post(url, form_data) + self.assertTemplateUsed(response, 'auth/login.html') + self.assertContains(response, "Invalid user name or password.") + + def test_exception(self): + user = self.data.user + + form_data = {'region': settings.OPENSTACK_KEYSTONE_URL, + 'password': user.password, + 'username': user.name} + + self.mox.StubOutWithMock(client, "Client") + + exc = keystone_exceptions.ClientException(500) + client.Client(auth_url=settings.OPENSTACK_KEYSTONE_URL, + password=user.password, + username=user.name, + tenant_id=None).AndRaise(exc) + + self.mox.ReplayAll() + + url = reverse('login') + + # GET the page to set the test cookie. + response = self.client.get(url, form_data) + self.assertEqual(response.status_code, 200) + + # POST to the page to log in. + response = self.client.post(url, form_data) + + self.assertTemplateUsed(response, 'auth/login.html') + self.assertContains(response, + ("An error occurred authenticating. Please try " + "again later.")) + + def test_switch(self): + tenant = self.data.tenant_two + tenants = [self.data.tenant_one, self.data.tenant_two] + user = self.data.user + scoped = self.data.scoped_token + sc = self.data.service_catalog + + form_data = {'region': settings.OPENSTACK_KEYSTONE_URL, + 'username': user.name, + 'password': user.password} + + self.mox.StubOutWithMock(client, "Client") + self.mox.StubOutWithMock(self.keystone_client.tenants, "list") + self.mox.StubOutWithMock(self.keystone_client.tokens, "authenticate") + + client.Client(auth_url=settings.OPENSTACK_KEYSTONE_URL, + password=user.password, + username=user.name, + tenant_id=None).AndReturn(self.keystone_client) + self.keystone_client.tenants.list().AndReturn(tenants) + self.keystone_client.tokens.authenticate(tenant_id=tenants[1].id, + token=sc.get_token()['id'], + username=user.name) \ + .AndReturn(scoped) + + client.Client(endpoint=settings.OPENSTACK_KEYSTONE_URL) \ + .AndReturn(self.keystone_client) + + self.keystone_client.tokens.authenticate(tenant_id=tenant.id, + token=sc.get_token()['id']) \ + .AndReturn(scoped) + + self.mox.ReplayAll() + + url = reverse('login') + + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + response = self.client.post(url, form_data) + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) + + url = reverse('switch_tenants', args=[tenant.id]) + + scoped.tenant['id'] = self.data.tenant_two._info + sc.catalog['token']['id'] = self.data.tenant_two.id + + form_data['tenant_id'] = tenant.id + response = self.client.get(url, form_data) + + self.assertRedirects(response, settings.LOGIN_REDIRECT_URL) + self.assertEqual(self.client.session['tenant_id'], + scoped.tenant['id']) diff --git a/openstack_auth/tests/urls.py b/openstack_auth/tests/urls.py new file mode 100644 index 0000000..e04d067 --- /dev/null +++ b/openstack_auth/tests/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls.defaults import patterns, include, url +from django.views.generic import TemplateView + +from openstack_auth.utils import patch_middleware_get_user + + +patch_middleware_get_user() + + +urlpatterns = patterns('', + url(r"", include('openstack_auth.urls')), + url(r"^$", TemplateView.as_view(template_name="auth/blank.html")) +) diff --git a/openstack_auth/urls.py b/openstack_auth/urls.py new file mode 100644 index 0000000..5e81a94 --- /dev/null +++ b/openstack_auth/urls.py @@ -0,0 +1,13 @@ +from django.conf.urls.defaults import patterns, url + +from .utils import patch_middleware_get_user + + +patch_middleware_get_user() + + +urlpatterns = patterns('openstack_auth.views', + url(r"^login/$", "login", name='login'), + url(r"^logout/$", 'logout', name='logout'), + url(r'^switch/(?P[^/]+)/$', 'switch', name='switch_tenants') +) diff --git a/openstack_auth/user.py b/openstack_auth/user.py new file mode 100644 index 0000000..ace98c8 --- /dev/null +++ b/openstack_auth/user.py @@ -0,0 +1,124 @@ +from django.contrib.auth.models import AnonymousUser + +from keystoneclient.v2_0 import client as keystone_client +from keystoneclient import exceptions as keystone_exceptions + +from .utils import check_token_expiration + + +def set_session_from_user(request, user): + request.session['serviceCatalog'] = user.service_catalog + request.session['tenant'] = user.tenant_name + request.session['tenant_id'] = user.tenant_id + request.session['token'] = user.token._info + request.session['username'] = user.username + request.session['user_id'] = user.id + request.session['roles'] = user.roles + request.session['region_endpoint'] = user.endpoint + + +def create_user_from_token(request, token, endpoint): + return User(id=token.user['id'], + token=token, + user=token.user['name'], + tenant_id=token.tenant['id'], + tenant_name=token.tenant['name'], + enabled=True, + service_catalog=token.serviceCatalog, + roles=token.user['roles'], + endpoint=endpoint) + + +class User(AnonymousUser): + """ A User class with some extra special sauce for Keystone. + + In addition to the standard Django user attributes, this class also has + the following: + + .. attribute:: token + + The Keystone token object associated with the current user/tenant. + + .. attribute:: tenant_id + + The id of the Keystone tenant for the current user/token. + + .. attribute:: tenant_name + + The name of the Keystone tenant for the current user/token. + + .. attribute:: service_catalog + + The ``ServiceCatalog`` data returned by Keystone. + + .. attribute:: roles + + A list of dictionaries containing role names and ids as returned + by Keystone. + """ + 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): + self.id = id + self.token = token + self.username = user + self.tenant_id = tenant_id + self.tenant_name = tenant_name + self.service_catalog = service_catalog + self.roles = roles or [] + self.endpoint = endpoint + self.enabled = enabled + self._authorized_tenants = authorized_tenants + + def __unicode__(self): + return self.username + + def __repr__(self): + return "<%s: %s>" % (self.__class__.__name__, self.username) + + def is_authenticated(self): + """ Checks for a valid token that has not yet expired. """ + return self.token is not None and check_token_expiration(self.token) + + @property + def is_active(self): + return self.enabled + + @property + def is_superuser(self): + """ + Evaluates whether this user has admin privileges. Returns + ``True`` or ``False``. + """ + for role in self.roles: + if role['name'].lower() == 'admin': + return True + return False + + @property + def authorized_tenants(self): + """ 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: + client = keystone_client.Client(username=self.username, + auth_url=endpoint, + token=token.id) + authd = client.tenants.list() + except keystone_exceptions.ClientException: + authd = [] + self._authorized_tenants = authd + return self._authorized_tenants or [] + + @authorized_tenants.setter + def authorized_tenants(self, tenant_list): + self._authorized_tenants = tenant_list + + def save(*args, **kwargs): + # Presume we can't write to Keystone. + pass + + def delete(*args, **kwargs): + # Presume we can't write to Keystone. + pass diff --git a/openstack_auth/utils.py b/openstack_auth/utils.py new file mode 100644 index 0000000..25bc093 --- /dev/null +++ b/openstack_auth/utils.py @@ -0,0 +1,55 @@ +from django.conf import settings +from django.contrib import auth +from django.contrib.auth.models import AnonymousUser +from django.contrib.auth import middleware +from django.utils import timezone +from django.utils.dateparse import parse_datetime + + +""" +We need the request object to get the user, so we'll slightly modify the +existing django.contrib.auth.get_user method. To do so we update the +auth middleware to point to our overridden method. + +Calling the "patch_middleware_get_user" method somewhere like our urls.py +file takes care of hooking it in appropriately. +""" + + +def middleware_get_user(request): + if not hasattr(request, '_cached_user'): + request._cached_user = get_user(request) + return request._cached_user + + +def get_user(request): + try: + user_id = request.session[auth.SESSION_KEY] + backend_path = request.session[auth.BACKEND_SESSION_KEY] + backend = auth.load_backend(backend_path) + backend.request = request + user = backend.get_user(user_id) or AnonymousUser() + except KeyError: + user = AnonymousUser() + return user + + +def patch_middleware_get_user(): + middleware.get_user = middleware_get_user + auth.get_user = get_user + + +""" End Monkey-Patching. """ + + +def check_token_expiration(token): + expiration = parse_datetime(token.expires) + if settings.USE_TZ and timezone.is_naive(expiration): + # Presumes that the Keystone is using UTC. + expiration = timezone.make_aware(expiration, timezone.utc) + # In case we get an unparseable expiration timestamp, return False + # so you can't have a "forever" token just by breaking the expires param. + if expiration: + return expiration > timezone.now() + else: + return False diff --git a/openstack_auth/views.py b/openstack_auth/views.py new file mode 100644 index 0000000..8bc9b6b --- /dev/null +++ b/openstack_auth/views.py @@ -0,0 +1,75 @@ +import logging + +from django import shortcuts +from django.conf import settings +from django.contrib.auth.views import (login as django_login, + logout_then_login as django_logout) +from django.contrib.auth.decorators import login_required +from django.views.decorators.debug import sensitive_post_parameters +from django.utils.functional import curry +from django.views.decorators.cache import never_cache +from django.views.decorators.csrf import csrf_protect + +from keystoneclient.v2_0 import client as keystone_client + +from .forms import Login +from .user import set_session_from_user, create_user_from_token + + +LOG = logging.getLogger(__name__) + + +@sensitive_post_parameters() +@csrf_protect +@never_cache +def login(request): + # Get our initial region for the form. + initial = {} + current_region = request.session.get('region_endpoint', None) + requested_region = request.GET.get('region', None) + regions = dict(getattr(settings, "AVAILABLE_REGIONS", [])) + if requested_region in regions and requested_region != current_region: + initial.update({'region': requested_region}) + + if request.method == "POST": + form = curry(Login, request) + else: + form = curry(Login, initial=initial) + + if request.is_ajax(): + template_name = 'auth/_login.html' + extra_context = {'hide': True} + else: + template_name = 'auth/login.html' + extra_context = {} + + res = django_login(request, + template_name=template_name, + authentication_form=form, + extra_context=extra_context) + # Set the session data here because django's session key rotation + # will erase it if we set it earlier. + if request.user.is_authenticated(): + set_session_from_user(request, request.user) + region = request.user.endpoint + region_name = dict(Login.get_region_choices()).get(region) + request.session['region_endpoint'] = region + request.session['region_name'] = region_name + return res + + +def logout(request): + return django_logout(request) + + +@login_required +def switch(request, tenant_id): + LOG.debug('Switching to tenant %s for user "%s".' + % (tenant_id, request.user.username)) + endpoint = request.user.endpoint + client = keystone_client.Client(endpoint=endpoint) + token = client.tokens.authenticate(tenant_id=tenant_id, + token=request.user.token.id) + user = create_user_from_token(request, token, endpoint) + set_session_from_user(request, user) + return shortcuts.redirect(settings.LOGIN_REDIRECT_URL) diff --git a/setup.py b/setup.py new file mode 100755 index 0000000..99fc94f --- /dev/null +++ b/setup.py @@ -0,0 +1,52 @@ +import os +import re +import codecs +from setuptools import setup, find_packages + + +def read(*parts): + return codecs.open(os.path.join(os.path.dirname(__file__), *parts)).read() + + +def find_version(*file_paths): + version_file = read(*file_paths) + version_match = re.search(r"^__version__ = ['\"]([^'\"]*)['\"]", + version_file, re.M) + if version_match: + return version_match.group(1) + raise RuntimeError("Unable to find version string.") + + +setup( + name="django_openstack_auth", + version=find_version("openstack_auth", "__init__.py"), + url='http://django_openstack_auth.readthedocs.org/', + license='BSD', + description=("A Django authentication backend for use with the " + "OpenStack Keystone Identity backend."), + long_description=read('README.rst'), + author='Gabriel Hurley', + author_email='gabriel@strikeawe.com', + packages=find_packages(), + include_package_data=True, + classifiers=[ + 'Development Status :: 5 - Production/Stable', + 'Framework :: Django', + 'Intended Audience :: Developers', + 'License :: OSI Approved :: BSD License', + 'Operating System :: OS Independent', + 'Programming Language :: Python', + 'Programming Language :: Python :: 2.6', + 'Programming Language :: Python :: 2.7', + 'Topic :: Internet :: WWW/HTTP', + ], + zip_safe=False, + install_requires=[ + 'django >= 1.4', + 'python-keystoneclient' + ], + tests_require=[ + 'mox', + ], + test_suite='openstack_auth.tests.run_tests.run' +)