mirror of
https://github.com/postgres/pgweb.git
synced 2025-08-13 13:12:42 +00:00
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:
@ -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
27
pgweb/account/admin.py
Normal 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)
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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, "-_"),
|
||||
))
|
||||
|
20
tools/communityauth/generate_cryptkey.py
Executable file
20
tools/communityauth/generate_cryptkey.py
Executable 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)
|
||||
|
64
tools/communityauth/test_auth.py
Executable file
64
tools/communityauth/test_auth.py
Executable 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, "-_"),
|
||||
)
|
Reference in New Issue
Block a user