From 5c2cc5b446e8b36c5b9ae0d404abdfdc77fc0c22 Mon Sep 17 00:00:00 2001 From: =?utf8?q?=C5=81ukasz=20Rekucki?= Date: Fri, 5 Mar 2010 03:08:51 +0100 Subject: [PATCH] New CAS client --- apps/django_cas/__init__.py | 25 ++++ apps/django_cas/backends.py | 93 +++++++++++++++ apps/django_cas/decorators.py | 45 +++++++ apps/django_cas/middleware.py | 52 ++++++++ apps/django_cas/models.py | 2 + apps/django_cas/views.py | 111 ++++++++++++++++++ apps/wiki/views.py | 2 +- platforma/settings.py | 23 +++- .../templates/registration/head_login.html | 4 +- .../templates/wiki/document_details.html | 6 +- platforma/urls.py | 9 +- 11 files changed, 354 insertions(+), 18 deletions(-) create mode 100755 apps/django_cas/__init__.py create mode 100755 apps/django_cas/backends.py create mode 100755 apps/django_cas/decorators.py create mode 100755 apps/django_cas/middleware.py create mode 100755 apps/django_cas/models.py create mode 100755 apps/django_cas/views.py diff --git a/apps/django_cas/__init__.py b/apps/django_cas/__init__.py new file mode 100755 index 00000000..14f71beb --- /dev/null +++ b/apps/django_cas/__init__.py @@ -0,0 +1,25 @@ +"""Django CAS 1.0/2.0 authentication backend""" + +from django.conf import settings + +__all__ = [] + +_DEFAULTS = { + 'CAS_ADMIN_PREFIX': None, + 'CAS_EXTRA_LOGIN_PARAMS': None, + 'CAS_IGNORE_REFERER': False, + 'CAS_LOGOUT_COMPLETELY': True, + 'CAS_REDIRECT_URL': '/', + 'CAS_RETRY_LOGIN': False, + 'CAS_SERVER_URL': None, + 'CAS_VERSION': '2', +} + +for key, value in _DEFAULTS.iteritems(): + try: + getattr(settings, key) + except AttributeError: + setattr(settings, key, value) + # Suppress errors from DJANGO_SETTINGS_MODULE not being set + except ImportError: + pass diff --git a/apps/django_cas/backends.py b/apps/django_cas/backends.py new file mode 100755 index 00000000..f14619d0 --- /dev/null +++ b/apps/django_cas/backends.py @@ -0,0 +1,93 @@ +"""CAS authentication backend""" + +from urllib import urlencode, urlopen +from urlparse import urljoin +from django.conf import settings +from django_cas.models import User + +__all__ = ['CASBackend'] + +def _verify_cas1(ticket, service): + """Verifies CAS 1.0 authentication ticket. + + Returns username on success and None on failure. + """ + + params = {'ticket': ticket, 'service': service} + url = (urljoin(settings.CAS_SERVER_URL, 'validate') + '?' + + urlencode(params)) + page = urlopen(url) + try: + verified = page.readline().strip() + if verified == 'yes': + return page.readline().strip() + else: + return None + finally: + page.close() + + +def _verify_cas2(ticket, service): + """Verifies CAS 2.0+ XML-based authentication ticket. + + Returns username on success and None on failure. + """ + + try: + from lxml import etree as ElementTree + except ImportError: + from elementtree import ElementTree + + params = {'ticket': ticket, 'service': service} + url = (urljoin(settings.CAS_SERVER_URL, 'serviceValidate') + '?' + + urlencode(params)) + page = urlopen(url) + try: + response = page.read() + tree = ElementTree.fromstring(response) + if tree[0].tag.endswith('authenticationSuccess'): + return tree[0][0].text + else: + return None + except: + import traceback + traceback.print_exc() + print "****" + print response + print "****" + finally: + page.close() + + +_PROTOCOLS = {'1': _verify_cas1, '2': _verify_cas2} + +if settings.CAS_VERSION not in _PROTOCOLS: + raise ValueError('Unsupported CAS_VERSION %r' % settings.CAS_VERSION) + +_verify = _PROTOCOLS[settings.CAS_VERSION] + + +class CASBackend(object): + """CAS authentication backend""" + + def authenticate(self, ticket, service): + """Verifies CAS ticket and gets or creates User object""" + + username = _verify(ticket, service) + if not username: + return None + try: + user = User.objects.get(username__iexact = username) + except User.DoesNotExist: + # user will have an "unusable" password + user = User.objects.create_user(username, '') + user.save() + return user + + def get_user(self, user_id): + """Retrieve the user's entry in the User model if it exists""" + + try: + return User.objects.get(pk = user_id) + except User.DoesNotExist: + return None diff --git a/apps/django_cas/decorators.py b/apps/django_cas/decorators.py new file mode 100755 index 00000000..54a3734f --- /dev/null +++ b/apps/django_cas/decorators.py @@ -0,0 +1,45 @@ +"""Replacement authentication decorators that work around redirection loops""" + +try: + from functools import wraps +except ImportError: + from django.utils.functional import wraps + +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.decorators import login_required +from django.http import HttpResponseForbidden, HttpResponseRedirect +from django.utils.http import urlquote + +__all__ = ['login_required', 'permission_required', 'user_passes_test'] + +def user_passes_test(test_func, login_url=None, + redirect_field_name=REDIRECT_FIELD_NAME): + """Replacement for django.contrib.auth.decorators.user_passes_test that + returns 403 Forbidden if the user is already logged in. + """ + + if not login_url: + from django.conf import settings + login_url = settings.LOGIN_URL + + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + if test_func(request.user): + return view_func(request, *args, **kwargs) + elif request.user.is_authenticated(): + return HttpResponseForbidden('

Permission denied

') + else: + path = '%s?%s=%s' % (login_url, redirect_field_name, + urlquote(request.get_full_path())) + return HttpResponseRedirect(path) + return wrapper + return decorator + + +def permission_required(perm, login_url=None): + """Replacement for django.contrib.auth.decorators.permission_required that + returns 403 Forbidden if the user is already logged in. + """ + + return user_passes_test(lambda u: u.has_perm(perm), login_url=login_url) diff --git a/apps/django_cas/middleware.py b/apps/django_cas/middleware.py new file mode 100755 index 00000000..e09f0634 --- /dev/null +++ b/apps/django_cas/middleware.py @@ -0,0 +1,52 @@ +"""CAS authentication middleware""" + +from urllib import urlencode + +from django.http import HttpResponseRedirect, HttpResponseForbidden +from django.conf import settings +from django.contrib.auth import REDIRECT_FIELD_NAME +from django.contrib.auth.views import login, logout +from django.core.urlresolvers import reverse + +from django_cas.views import login as cas_login, logout as cas_logout + +__all__ = ['CASMiddleware'] + +class CASMiddleware(object): + """Middleware that allows CAS authentication on admin pages""" + + def process_request(self, request): + """Checks that the authentication middleware is installed""" + + error = ("The Django CAS middleware requires authentication " + "middleware to be installed. Edit your MIDDLEWARE_CLASSES " + "setting to insert 'django.contrib.auth.middleware." + "AuthenticationMiddleware'.") + assert hasattr(request, 'user'), error + + def process_view(self, request, view_func, view_args, view_kwargs): + """Forwards unauthenticated requests to the admin page to the CAS + login URL, as well as calls to django.contrib.auth.views.login and + logout. + """ + + if view_func == login: + return cas_login(request, *view_args, **view_kwargs) + elif view_func == logout: + return cas_logout(request, *view_args, **view_kwargs) + + if settings.CAS_ADMIN_PREFIX: + if not request.path.startswith(settings.CAS_ADMIN_PREFIX): + return None + elif not view_func.__module__.startswith('django.contrib.admin.'): + return None + + if request.user.is_authenticated(): + if request.user.is_staff: + return None + else: + error = ('

Forbidden

You do not have staff ' + 'privileges.

') + return HttpResponseForbidden(error) + params = urlencode({REDIRECT_FIELD_NAME: request.get_full_path()}) + return HttpResponseRedirect(reverse(cas_login) + '?' + params) diff --git a/apps/django_cas/models.py b/apps/django_cas/models.py new file mode 100755 index 00000000..7658df9a --- /dev/null +++ b/apps/django_cas/models.py @@ -0,0 +1,2 @@ +from django.db import models +from django.contrib.auth.models import User \ No newline at end of file diff --git a/apps/django_cas/views.py b/apps/django_cas/views.py new file mode 100755 index 00000000..110c6465 --- /dev/null +++ b/apps/django_cas/views.py @@ -0,0 +1,111 @@ +"""CAS login/logout replacement views""" + +from urllib import urlencode +from urlparse import urljoin + +from django.http import get_host, HttpResponseRedirect, HttpResponseForbidden +from django.conf import settings +from django.contrib.auth import REDIRECT_FIELD_NAME + +__all__ = ['login', 'logout'] + +def _service_url(request, redirect_to = None): + """Generates application service URL for CAS""" + + protocol = ('http://', 'https://')[request.is_secure()] + host = get_host(request) + service = protocol + host + request.path + if redirect_to: + if '?' in service: + service += '&' + else: + service += '?' + service += urlencode({REDIRECT_FIELD_NAME: redirect_to.encode('utf-8')}) + return service + + +def _redirect_url(request): + """Redirects to referring page, or CAS_REDIRECT_URL if no referrer is + set. + """ + + next = request.GET.get(REDIRECT_FIELD_NAME) + if not next: + if settings.CAS_IGNORE_REFERER: + next = settings.CAS_REDIRECT_URL + else: + next = request.META.get('HTTP_REFERER', settings.CAS_REDIRECT_URL) + prefix = (('http://', 'https://')[request.is_secure()] + + get_host(request)) + if next.startswith(prefix): + next = next[len(prefix):] + return next + + +def _login_url(service): + """Generates CAS login URL""" + + params = {'service': service} + if settings.CAS_EXTRA_LOGIN_PARAMS: + params.update(settings.CAS_EXTRA_LOGIN_PARAMS) + return urljoin(settings.CAS_SERVER_URL, 'login') + '?' + urlencode(params) + + +def _logout_url(request, next_page = None): + """Generates CAS logout URL""" + + url = urljoin(settings.CAS_SERVER_URL, 'logout') + if next_page: + protocol = ('http://', 'https://')[request.is_secure()] + host = get_host(request) + url += '?' + urlencode({'url': protocol + host + next_page}) + return url + + +def login(request, next_page = None, required = False): + """Forwards to CAS login URL or verifies CAS ticket""" + + print "LOGIN original NEXT_PAGE:", next_page + print request.GET + if not next_page: + next_page = _redirect_url(request) + print "LOGIN redirect NEXT_PAGE:", next_page + + if request.user.is_authenticated(): + message = "You are logged in as %s." % request.user.username + request.user.message_set.create(message = message) + return HttpResponseRedirect(next_page) + ticket = request.GET.get('ticket') + service = _service_url(request, next_page) + print "TICKET", ticket + print "SERVICE", service + if ticket: + from django.contrib import auth + user = auth.authenticate(ticket = ticket, service = service) + if user is not None: + auth.login(request, user) + name = user.first_name or user.username + message = "Login succeeded. Welcome, %s." % name + user.message_set.create(message = message) + return HttpResponseRedirect(next_page) + elif settings.CAS_RETRY_LOGIN or required: + return HttpResponseRedirect(_login_url(service)) + else: + error = "

Forbidden

Login failed.

" + return HttpResponseForbidden(error) + + else: + return HttpResponseRedirect(_login_url(service)) + + +def logout(request, next_page = None): + """Redirects to CAS logout page""" + + from django.contrib.auth import logout + logout(request) + if not next_page: + next_page = _redirect_url(request) + if settings.CAS_LOGOUT_COMPLETELY: + return HttpResponseRedirect(_logout_url(request, next_page)) + else: + return HttpResponseRedirect(next_page) diff --git a/apps/wiki/views.py b/apps/wiki/views.py index eff1f1db..4f808f0d 100644 --- a/apps/wiki/views.py +++ b/apps/wiki/views.py @@ -40,7 +40,7 @@ def document_detail(request, name, template_name = 'wiki/document_details.html') def document_gallery(request, directory): try: base_dir = os.path.join(settings.MEDIA_ROOT, settings.FILEBROWSER_DIRECTORY, directory) - images = ['%s%s%s/%s' % (settings.MEDIA_URL, settings.FILEBROWSER_DIRECTORY, directory, f) for f in os.listdir(base_dir) if os.path.splitext(f)[1].lower() in ('.jpg', '.jpeg', '.png')] + images = [u'%s%s%s/%s' % (settings.MEDIA_URL, settings.FILEBROWSER_DIRECTORY, directory, f) for f in os.listdir(base_dir) if os.path.splitext(f)[1].lower() in (u'.jpg', u'.jpeg', u'.png')] images.sort() return HttpResponse(json.dumps(images)) except (IndexError, OSError), e: diff --git a/platforma/settings.py b/platforma/settings.py index 2faab1b3..c7898e89 100755 --- a/platforma/settings.py +++ b/platforma/settings.py @@ -79,16 +79,31 @@ MIDDLEWARE_CLASSES = ( 'django.middleware.common.CommonMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django_cas.middleware.CASMiddleware', 'django.middleware.doc.XViewMiddleware', 'maintenancemode.middleware.MaintenanceModeMiddleware', ) +AUTHENTICATION_BACKENDS = ( + 'django.contrib.auth.backends.ModelBackend', + 'django_cas.backends.CASBackend', +) + ROOT_URLCONF = 'urls' TEMPLATE_DIRS = ( PROJECT_ROOT + '/templates', ) + +# +# Central Auth System +# +## Set this to where the CAS server lives +# CAS_SERVER_URL = "http://cas.fnp.pl/ +CAS_ADMIN_PREFIX = "/admin/" +CAS_LOGOUT_COMPLETELY = True + # CSS and JS files to compress # COMPRESS_CSS = { # 'all': { @@ -124,6 +139,11 @@ INSTALLED_APPS = ( 'toolbar', ) + +# +# Nose tests +# + TEST_RUNNER = 'django_nose.run_tests' TEST_MODULES = ('wiki', 'toolbar', 'vstorage') NOSE_ARGS = ( @@ -155,12 +175,9 @@ formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(messag ch.setFormatter(formatter) log.addHandler(ch) - # Import localsettings file, which may override settings defined here try: - EXTRA_INSTALLED_APPS = tuple() from localsettings import * - INSTALLED_APPS += EXTRA_INSTALLED_APPS except ImportError: pass diff --git a/platforma/templates/registration/head_login.html b/platforma/templates/registration/head_login.html index ca2c4e1f..ee20ef29 100644 --- a/platforma/templates/registration/head_login.html +++ b/platforma/templates/registration/head_login.html @@ -2,10 +2,10 @@ {% if user.is_authenticated %} {{ user.username }} | -{% trans "Log Out" %} +{% trans "Log Out" %} {% else %} {% url login as login_url %} {% ifnotequal login_url request.path %} - {% trans "Log In" %} + {% trans "Log In" %} {% endifnotequal %} {% endif %} diff --git a/platforma/templates/wiki/document_details.html b/platforma/templates/wiki/document_details.html index ead13824..43e5dfd3 100644 --- a/platforma/templates/wiki/document_details.html +++ b/platforma/templates/wiki/document_details.html @@ -28,11 +28,7 @@