diff --git a/docs/authentication.rst b/docs/authentication.rst index 4fc2291a..5e956565 100644 --- a/docs/authentication.rst +++ b/docs/authentication.rst @@ -18,3 +18,95 @@ In a local installation that does not have access to the existing set of users, this authentication backend can be disabled completely, and the system will function perfectly fine relying on just the django authentication system. + + +Community authentication 2.0 +============================ +While the old community authentication system was simply having the +clients call a PostgreSQL function on the main website server, version +2.0 of the system uses browser redirects to perform this. This allows +for a number of advantages: + +* The users password never hits the "community sites", only the main + website. This has some obvious security advantages. +* There is no need to allow external access from these sites to the + PostgreSQL database on the main website. +* It is possible for the user to get single sign-on between all the + community sites, not just same-password. + +Each community site is still registered in the central system, to hold +encryption keys and similar details. This is now held in the main +database, accessible through the django administration system, instead +of being held in pg_hba.conf and managed through SQL. + +In some cases this may be complicated to implement on the client side, +and thus version 1.0 community login is still left around. It may +be removed at some point in the future, depending on implementation +and policy details... + +The flow of an authentication in the 2.0 system is fairly simple: + +#. The user tries to access a protected resource on the community + site. +#. At this point, the user is redirected to an URL on the main + website, specifically https://www.postgresql.org/account/auth//. + The number in this URL is unique for each site, and is the + identifier that accesses all encryption keys and redirection + information. + In this call, the client site can optionally include a parameter + *su*, which will be used in the final redirection step. This URL + must start with a / to be considered, to prevent cross site + redirection. +#. The main website will check if the user holds a valid, logged in, + session on the main website. If it does not, the user will be + sent through the standard login path on the main website, and once + that is done will be sent to the next step in this process. +#. The main website puts together a dictionary of information about + the logged in user, that contains the following fields: + + u + The username of the user logged in + f + The first name of the user logged in + l + The last name of the user logged in + e + The email address of the user logged in + su + The suburl to redirect to (optional) + +#. This dictionary of information is then URL-encoded. +#. The resulting URL-encoded string is padded with spaces to an even + 16 bytes, and is then AES encrypted with a shared key. This key + is stored in the main website system and indexed by the site id, + and it is stored in the settings of the community website somewhere. + Since this key is what protects the authentication, it should be + treated as very valuable. +#. The resulting encrypted string and the IV used for the encryption are + base64-encoded (in URL mode, meaning it uses - and _ instead of + and /. +#. The main website looks up the redirection URL registered for this site + (again indexed by the site id), and constructs an URL of the format + ?i=&d= +#. The user browser is redirected to this URL. +#. The community website detects that this is a redirected authentication + response, and stars processing it specifically. +#. Using the shared key, the data is decrypted (while first being base64 + decoded, of course) +#. The resulting string is urldecoded - and if any errors occur in the + decoding, the authentication will fail. This step is guaranteed to fail + if the encryption key is mismatching between the community site and + the main website, since it is going to end up with something that is + definitely not an url-decodeable string. +#. The community site will look up an existing user record under this + username, or create a new one if one does not exist already (assuming + the site keeps local track of users at all - if it just deals with + session users, it can just store this information in the session). + It is recommended that the community site verifies if the first name, + last name or email field has changed, and updates the local record if + this is the case. +#. The community site logs the user in using whatever method it's framework + uses. +#. If the *su* key is present in the data structure handed over, the + community site redirects to this location. If it's not present, then + the community site will redirect so some default location on the + site. diff --git a/pgweb/account/admin.py b/pgweb/account/admin.py new file mode 100644 index 00000000..e979242b --- /dev/null +++ b/pgweb/account/admin.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from django import forms + +import base64 + +from models import * + +class CommunityAuthSiteAdminForm(forms.ModelForm): + class Meta: + model = CommunityAuthSite + + def clean_cryptkey(self): + x = None + try: + x = base64.b64decode(self.cleaned_data['cryptkey']) + except TypeError, e: + raise forms.ValidationError("Crypto key must be base64 encoded") + + if (len(x) != 16 and len(x) != 24 and len(x) != 32): + raise forms.ValidationError("Crypto key must be 16, 24 or 32 bytes before being base64-encoded") + return self.cleaned_data['cryptkey'] + +class CommunityAuthSiteAdmin(admin.ModelAdmin): + form = CommunityAuthSiteAdminForm + + +admin.site.register(CommunityAuthSite, CommunityAuthSiteAdmin) diff --git a/pgweb/account/models.py b/pgweb/account/models.py index 71a83623..625cfe7d 100644 --- a/pgweb/account/models.py +++ b/pgweb/account/models.py @@ -1,3 +1,9 @@ from django.db import models -# Create your models here. +class CommunityAuthSite(models.Model): + name = models.CharField(max_length=100, null=False, blank=False) + redirecturl = models.URLField(max_length=200, null=False, blank=False) + cryptkey = models.CharField(max_length=100, null=False, blank=False) + + def __unicode__(self): + return self.name diff --git a/pgweb/account/urls.py b/pgweb/account/urls.py index 6d54cb72..71f96a5d 100644 --- a/pgweb/account/urls.py +++ b/pgweb/account/urls.py @@ -4,6 +4,9 @@ from django.conf.urls.defaults import * urlpatterns = patterns('', (r'^$', 'account.views.home'), + # Community authenticatoin + (r'^auth/(\d+)/$', 'account.views.communityauth'), + # Profile (r'^profile/$', 'account.views.profile'), # List of items to edit diff --git a/pgweb/account/views.py b/pgweb/account/views.py index 06f4fde5..dd7aff23 100644 --- a/pgweb/account/views.py +++ b/pgweb/account/views.py @@ -1,12 +1,17 @@ from django.contrib.auth.models import User import django.contrib.auth.views as authviews from django.http import HttpResponseRedirect, HttpResponse, Http404 -from django.shortcuts import render_to_response +from django.shortcuts import render_to_response, get_object_or_404 from django.contrib.auth.decorators import login_required from django.utils.http import int_to_base36 from django.contrib.auth.tokens import default_token_generator from django.conf import settings +import base64 +import urllib +from Crypto.Cipher import AES +from Crypto import Random + from pgweb.util.decorators import ssl_required from pgweb.util.contexts import NavContext from pgweb.util.misc import send_template_mail @@ -18,6 +23,7 @@ from pgweb.core.models import Organisation, UserProfile from pgweb.downloads.models import Product from pgweb.profserv.models import ProfessionalService +from models import CommunityAuthSite from forms import SignupForm, UserProfileForm @ssl_required @@ -155,3 +161,46 @@ content is available for reading without an account. def signup_complete(request): return render_to_response('account/signup_complete.html', { }, NavContext(request, 'account')) + + + +#### +## Community authentication endpoint +#### +@ssl_required +@login_required +def communityauth(request, siteid): + # When we reach this point, the user *has* already been authenticated. + # The request variable "su" *may* contain a suburl and should in that + # case be passed along to the site we're authenticating for. And of + # course, we fill a structure with information about the user. + + site = get_object_or_404(CommunityAuthSite, pk=siteid) + + info = { + 'u': request.user.username, + 'f': request.user.first_name, + 'l': request.user.last_name, + 'e': request.user.email, + } + if request.GET.has_key('su'): + if request.GET['su'].startswith('/'): + info.update({ + 'su': request.GET['su'] + }) + + # URL-encode the structure + s = urllib.urlencode(info) + + # Encrypt it with the shared key (and IV!) + r = Random.new() + iv = r.read(16) # Always 16 bytes for AES + encryptor = AES.new(base64.b64decode(site.cryptkey), AES.MODE_CBC, iv) + cipher = encryptor.encrypt(s + ' ' * (16-(len(s) % 16))) #Pad to even 16 bytes + + # Generate redirect + return HttpResponseRedirect("%s?i=%s&d=%s" % ( + site.redirecturl, + base64.b64encode(iv, "-_"), + base64.b64encode(cipher, "-_"), + )) diff --git a/tools/communityauth/generate_cryptkey.py b/tools/communityauth/generate_cryptkey.py new file mode 100755 index 00000000..e39cb0b4 --- /dev/null +++ b/tools/communityauth/generate_cryptkey.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python + +# +# This script generates a crypto key that can be used for +# community authentication integration. +# + +from Crypto import Random +import base64 + +if __name__ == "__main__": + print "The next row contains a 32-byte (256-bit) symmetric crypto key." + print "This key should be used to integrate a community auth site." + print "Note that each site should have it's own key!!" + print "" + + r = Random.new() + key = r.read(32) + print base64.b64encode(key) + diff --git a/tools/communityauth/test_auth.py b/tools/communityauth/test_auth.py new file mode 100755 index 00000000..792f053f --- /dev/null +++ b/tools/communityauth/test_auth.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python + +# +# This script generates a URL valid for a test authentication, +# so the full website integration isn't necessary. +# + +import sys +from Crypto import Random +from Crypto.Cipher import AES +from urllib import quote_plus +import base64 +import urllib +from optparse import OptionParser + + +if __name__ == "__main__": + parser = OptionParser() + parser.add_option("-k", "--key", dest="key") + parser.add_option("-u", "--user", dest="user") + parser.add_option("-f", "--first", dest="first") + parser.add_option("-l", "--last", dest="last") + parser.add_option("-e", "--email", dest="email") + parser.add_option("-s", "--suburl", dest="suburl") + + (options, args) = parser.parse_args() + + if len(args) != 0: + parser.print_usage() + sys.exit(1) + + if not options.key: + options.key = raw_input("Enter key (BASE64 encoded): ") + if not options.user: + options.user = raw_input("Enter username: ") + if not options.first: + options.first = "FirstName" + if not options.last: + options.last = "LastName" + if not options.email: + options.email = "test@example.com" + + # This is basically a rip of the view in accounts/views.py + info = { + 'u': options.user, + 'f': options.first, + 'l': options.last, + 'e': options.email, + } + if options.suburl: + info['su'] = options.suburl + + s = urllib.urlencode(info) + + r = Random.new() + iv = r.read(16) + encryptor = AES.new(base64.b64decode(options.key), AES.MODE_CBC, iv) + cipher = encryptor.encrypt(s + ' ' * (16-(len(s) % 16))) + + print "Paste the following after the receiving url:" + print "?i=%s&d=%s" % ( + base64.b64encode(iv, "-_"), + base64.b64encode(cipher, "-_"), + )