diff options
author | Dave Page | 2016-03-15 11:31:53 +0000 |
---|---|---|
committer | Dave Page | 2016-03-15 11:31:53 +0000 |
commit | e52643477ef45cfe03a44da9f675b77da5656ff3 (patch) | |
tree | 0a1e1a9a7ec20912ec150b1cc6343de66f7f67be | |
parent | ee46393b077a969361e4201bc9088652500f8b4b (diff) |
Add PG Community Auth support.
-rw-r--r-- | web/pgperffarm/auth.py | 205 | ||||
-rw-r--r-- | web/pgperffarm/settings.py | 4 | ||||
-rw-r--r-- | web/pgperffarm/settings_local.py.in | 2 | ||||
-rw-r--r-- | web/pgperffarm/urls.py | 7 | ||||
-rw-r--r-- | web/pgperffarm/views.py | 12 | ||||
-rw-r--r-- | web/templates/admin/login.html | 11 | ||||
-rw-r--r-- | web/templates/body.html | 11 |
7 files changed, 242 insertions, 10 deletions
diff --git a/web/pgperffarm/auth.py b/web/pgperffarm/auth.py new file mode 100644 index 0000000..5ce4593 --- /dev/null +++ b/web/pgperffarm/auth.py @@ -0,0 +1,205 @@ +# +# Django module to support postgresql.org community authentication 2.0 +# +# The main location for this module is the pgweb git repository hosted +# on git.postgresql.org - look there for updates. +# +# To integrate with django, you need the following: +# * Make sure the view "login" from this module is used for login +# * Map an url somwehere (typically /auth_receive/) to the auth_receive +# view. +# * In settings.py, set AUTHENTICATION_BACKENDS to point to the class +# AuthBackend in this module. +# * (And of course, register for a crypto key with the main authentication +# provider website) +# * If the application uses the django admin interface, the login screen +# has to be replaced with something similar to login.html in this +# directory (adjust urls, and name it admin/login.html in any template +# directory that's processed before the default django.contrib.admin) +# + +from django.http import HttpResponse, HttpResponseRedirect +from django.contrib.auth.models import User +from django.contrib.auth.backends import ModelBackend +from django.contrib.auth import login as django_login +from django.contrib.auth import logout as django_logout +from django.conf import settings + +import base64 +import json +import socket +import urlparse +import urllib +from Crypto.Cipher import AES +from Crypto.Hash import SHA +from Crypto import Random +import time + +class AuthBackend(ModelBackend): + # We declare a fake backend that always fails direct authentication - + # since we should never be using direct authentication in the first place! + def authenticate(self, username=None, password=None): + raise Exception("Direct authentication not supported") + + +#### +# Two regular django views to interact with the login system +#### + +# Handle login requests by sending them off to the main site +def login(request): + if request.GET.has_key('next'): + # Put together an url-encoded dict of parameters we're getting back, + # including a small nonce at the beginning to make sure it doesn't + # encrypt the same way every time. + s = "t=%s&%s" % (int(time.time()), urllib.urlencode({'r': request.GET['next']})) + # Now encrypt it + r = Random.new() + iv = r.read(16) + encryptor = AES.new(SHA.new(settings.SECRET_KEY).digest()[:16], AES.MODE_CBC, iv) + cipher = encryptor.encrypt(s + ' ' * (16-(len(s) % 16))) # pad to 16 bytes + + return HttpResponseRedirect("%s?d=%s$%s" % ( + settings.PGAUTH_REDIRECT, + base64.b64encode(iv, "-_"), + base64.b64encode(cipher, "-_"), + )) + else: + return HttpResponseRedirect(settings.PGAUTH_REDIRECT) + +# Handle logout requests by logging out of this site and then +# redirecting to log out from the main site as well. +def logout(request): + if request.user.is_authenticated(): + django_logout(request) + return HttpResponseRedirect("%slogout/" % settings.PGAUTH_REDIRECT) + +# Receive an authentication response from the main website and try +# to log the user in. +def auth_receive(request): + if request.GET.has_key('s') and request.GET['s'] == "logout": + # This was a logout request + return HttpResponseRedirect('/') + + if not request.GET.has_key('i'): + return HttpResponse("Missing IV in url!", status=400) + if not request.GET.has_key('d'): + return HttpResponse("Missing data in url!", status=400) + + # Set up an AES object and decrypt the data we received + decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY), + AES.MODE_CBC, + base64.b64decode(str(request.GET['i']), "-_")) + s = decryptor.decrypt(base64.b64decode(str(request.GET['d']), "-_")).rstrip(' ') + + # Now un-urlencode it + try: + data = urlparse.parse_qs(s, strict_parsing=True) + except ValueError: + return HttpResponse("Invalid encrypted data received.", status=400) + + # Check the timestamp in the authentication + if (int(data['t'][0]) < time.time() - 10): + return HttpResponse("Authentication token too old.", status=400) + + # Update the user record (if any) + try: + user = User.objects.get(username=data['u'][0]) + # User found, let's see if any important fields have changed + changed = False + if user.first_name != data['f'][0]: + user.first_name = data['f'][0] + changed = True + if user.last_name != data['l'][0]: + user.last_name = data['l'][0] + changed = True + if user.email != data['e'][0]: + user.email = data['e'][0] + changed= True + if changed: + user.save() + except User.DoesNotExist: + # User not found, create it! + + # NOTE! We have some legacy users where there is a user in + # the database with a different userid. Instead of trying to + # somehow fix that live, give a proper error message and + # have somebody look at it manually. + if User.objects.filter(email=data['e'][0]).exists(): + return HttpResponse("""A user with email %s already exists, but with +a different username than %s. + +This is almost certainly caused by some legacy data in our database. +Please send an email to [email protected], indicating the username +and email address from above, and we'll manually merge the two accounts +for you. + +We apologize for the inconvenience. +""" % (data['e'][0], data['u'][0]), content_type='text/plain') + + user = User(username=data['u'][0], + first_name=data['f'][0], + last_name=data['l'][0], + email=data['e'][0], + password='setbypluginnotasha1', + ) + user.save() + + # Ok, we have a proper user record. Now tell django that + # we're authenticated so it persists it in the session. Before + # we do that, we have to annotate it with the backend information. + user.backend = "%s.%s" % (AuthBackend.__module__, AuthBackend.__name__) + django_login(request, user) + + # Finally, check of we have a data package that tells us where to + # redirect the user. + if data.has_key('d'): + (ivs, datas) = data['d'][0].split('$') + decryptor = AES.new(SHA.new(settings.SECRET_KEY).digest()[:16], + AES.MODE_CBC, + base64.b64decode(ivs, "-_")) + s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(' ') + try: + rdata = urlparse.parse_qs(s, strict_parsing=True) + except ValueError: + return HttpResponse("Invalid encrypted data received.", status=400) + if rdata.has_key('r'): + # Redirect address + return HttpResponseRedirect(rdata['r'][0]) + # No redirect specified, see if we have it in our settings + if hasattr(settings, 'PGAUTH_REDIRECT_SUCCESS'): + return HttpResponseRedirect(settings.PGAUTH_REDIRECT_SUCCESS) + return HttpResponse("Authentication successful, but don't know where to redirect!", status=500) + + +# Perform a search in the central system. Note that the results are returned as an +# array of dicts, and *not* as User objects. To be able to for example reference the +# user through a ForeignKey, a User object must be materialized locally. We don't do +# that here, as this search might potentially return a lot of unrelated users since +# it's a wildcard match. +# Unlike the authentication, searching does not involve the browser - we just make +# a direct http call. +def user_search(searchterm=None, userid=None): + # If upsteam isn't responding quickly, it's not going to respond at all, and + # 10 seconds is already quite long. + socket.setdefaulttimeout(10) + if userid: + q = {'u': userid} + else: + q = {'s': searchterm} + + u = urllib.urlopen('%ssearch/?%s' % ( + settings.PGAUTH_REDIRECT, + urllib.urlencode(q), + )) + (ivs, datas) = u.read().split('&') + u.close() + + # Decryption time + decryptor = AES.new(base64.b64decode(settings.PGAUTH_KEY), + AES.MODE_CBC, + base64.b64decode(ivs, "-_")) + s = decryptor.decrypt(base64.b64decode(datas, "-_")).rstrip(' ') + j = json.loads(s) + + return j diff --git a/web/pgperffarm/settings.py b/web/pgperffarm/settings.py index 8381541..29d74dc 100644 --- a/web/pgperffarm/settings.py +++ b/web/pgperffarm/settings.py @@ -106,5 +106,9 @@ STATICFILES_DIRS = [ "static/", ] +AUTHENTICATION_BACKENDS = ( + 'pgperffarm.auth.AuthBackend', +) + # Load local settings overrides from settings_local import * diff --git a/web/pgperffarm/settings_local.py.in b/web/pgperffarm/settings_local.py.in index fbe4ac6..39618d4 100644 --- a/web/pgperffarm/settings_local.py.in +++ b/web/pgperffarm/settings_local.py.in @@ -10,3 +10,5 @@ DATABASES={ } } +PGAUTH_REDIRECT='' +PGAUTH_KEY=''
\ No newline at end of file diff --git a/web/pgperffarm/urls.py b/web/pgperffarm/urls.py index e07097f..ea9962b 100644 --- a/web/pgperffarm/urls.py +++ b/web/pgperffarm/urls.py @@ -21,7 +21,12 @@ urlpatterns = [ url(r'^$', 'pgperffarm.views.index', name='index'), url(r'^/licence$', 'pgperffarm.views.licence', name='licence'), url(r'^/ppolicy$', 'pgperffarm.views.ppolicy', name='ppolicy'), - + + # Auth system integration + url(r'^(?:account/)?login/?$', 'pgperffarm.auth.login'), + url(r'^(?:account/)?logout/?$', 'pgperffarm.auth.logout'), + url(r'^auth_receive/$', 'pgperffarm.auth.auth_receive'), + # Admin site url(r'^admin/', include(admin.site.urls)), diff --git a/web/pgperffarm/views.py b/web/pgperffarm/views.py index ecdf975..7fdcf34 100644 --- a/web/pgperffarm/views.py +++ b/web/pgperffarm/views.py @@ -1,17 +1,19 @@ """Views for the core PGPerfFarm app""" from django.shortcuts import render_to_response -from django.http import HttpResponse, Http404 -from django.template import TemplateDoesNotExist, loader, Context +from django.template import RequestContext import datetime # Handle the static pages def index(request): - return render_to_response('index.html') + return render_to_response('index.html', + context_instance=RequestContext(request)) def licence(request): - return render_to_response('licence.html') + return render_to_response('licence.html', + context_instance=RequestContext(request)) def ppolicy(request): - return render_to_response('ppolicy.html')
\ No newline at end of file + return render_to_response('ppolicy.html', + context_instance=RequestContext(request))
\ No newline at end of file diff --git a/web/templates/admin/login.html b/web/templates/admin/login.html new file mode 100644 index 0000000..e9e7257 --- /dev/null +++ b/web/templates/admin/login.html @@ -0,0 +1,11 @@ +<html> +<head> +<meta http-equiv="refresh" content="0;url={% url 'pgperffarm.auth.login' %}/?next=/admin/"></meta> +</head> +<body> +<h1>Redirect</h1> +<p> +Redirect <a href="{% url 'pgperffarm.auth.login' %}/?next=/admin/">here</a> +</p> +</body> +</html>
\ No newline at end of file diff --git a/web/templates/body.html b/web/templates/body.html index 7e06dad..08313b5 100644 --- a/web/templates/body.html +++ b/web/templates/body.html @@ -38,16 +38,19 @@ </li> </ul> <ul class="nav navbar-nav navbar-right"> + {% if user.is_authenticated %} <li class="dropdown"> - <a href="#" class="dropdown-toggle" data-toggle="dropdown">{% gravatar user.email 18 %} {{ request.user }}<b class="caret"></b></a> + <a href="#" class="dropdown-toggle" data-toggle="dropdown">{% gravatar user.email 18 %} {{ user }}<b class="caret"></b></a> <ul class="dropdown-menu"> - {% if request.user.is_superuser %} + {% if user.is_staff %} <li><a href="{% url 'admin:index' %}">Admin Site</a></li> - <li><a href="{% url 'django-admindocs-docroot' %}">Admin Docs</a></li> {% endif %} - <li><a href="#">Logout</a></li> + <li><a href="{% url 'pgperffarm.auth.logout' %}">Logout</a></li> </ul> </li> + {% else %} + <li><a href="{% url 'pgperffarm.auth.login' %}/?next={{ request.path }}">Login</a></li> + {% endif %} </ul> </div> </div> |