Implement community authentication 2.0

This system relies on http redirects and signing in to the main website
instead of using cross-internet pgsql connections and signing in individually
to each website.
This commit is contained in:
Magnus Hagander
2011-12-18 16:55:39 +01:00
parent b68e68fecf
commit 1f78460779
7 changed files with 263 additions and 2 deletions

View File

@ -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/<id>/.
The <id> 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
<redirection_url>?i=<iv>&d=<encrypted data>
#. 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.

27
pgweb/account/admin.py Normal file
View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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, "-_"),
))

View File

@ -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)

View File

@ -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, "-_"),
)