[snowy: 1/26] Add django_openid_auth library



commit 903abf482dd124e93091c52d1acbd082cb490e14
Author: Leon Handreke <leon handreke gmail com>
Date:   Wed Mar 17 20:42:35 2010 +0100

    Add django_openid_auth library

 lib/django_openid_auth/LICENSE.txt                 |   23 +
 lib/django_openid_auth/__init__.py                 |   29 ++
 lib/django_openid_auth/admin.py                    |   90 +++++
 lib/django_openid_auth/auth.py                     |  183 +++++++++
 lib/django_openid_auth/forms.py                    |   87 ++++
 lib/django_openid_auth/management/__init__.py      |   27 ++
 .../management/commands/__init__.py                |   27 ++
 .../management/commands/openid_cleanup.py          |   39 ++
 lib/django_openid_auth/models.py                   |   58 +++
 lib/django_openid_auth/store.py                    |  131 ++++++
 lib/django_openid_auth/teams.py                    |  411 +++++++++++++++++++
 .../templates/openid/failure.html                  |   13 +
 lib/django_openid_auth/templates/openid/login.html |   44 ++
 lib/django_openid_auth/tests/__init__.py           |   37 ++
 lib/django_openid_auth/tests/test_store.py         |  193 +++++++++
 lib/django_openid_auth/tests/test_views.py         |  423 ++++++++++++++++++++
 lib/django_openid_auth/tests/urls.py               |   39 ++
 lib/django_openid_auth/urls.py                     |   36 ++
 lib/django_openid_auth/views.py                    |  239 +++++++++++
 settings.py                                        |    5 +
 urls.py                                            |    2 +-
 21 files changed, 2135 insertions(+), 1 deletions(-)
---
diff --git a/lib/django_openid_auth/LICENSE.txt b/lib/django_openid_auth/LICENSE.txt
new file mode 100644
index 0000000..0e67faf
--- /dev/null
+++ b/lib/django_openid_auth/LICENSE.txt
@@ -0,0 +1,23 @@
+Copyright (C) 2007 Simon Willison
+Copyright (C) 2008-2009 Canonical Ltd.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/django_openid_auth/__init__.py b/lib/django_openid_auth/__init__.py
new file mode 100644
index 0000000..4e5da8c
--- /dev/null
+++ b/lib/django_openid_auth/__init__.py
@@ -0,0 +1,29 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2007 Simon Willison
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
diff --git a/lib/django_openid_auth/admin.py b/lib/django_openid_auth/admin.py
new file mode 100644
index 0000000..ac7906c
--- /dev/null
+++ b/lib/django_openid_auth/admin.py
@@ -0,0 +1,90 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2008-2009 Canonical Ltd.
+# Copyright (C) 2010 Dave Walker
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from django.conf import settings
+from django.contrib import admin
+from django_openid_auth.models import Nonce, Association, UserOpenID
+from django_openid_auth.store import DjangoOpenIDStore
+
+
+class NonceAdmin(admin.ModelAdmin):
+    list_display = ('server_url', 'timestamp')
+    actions = ['cleanup_nonces']
+
+    def cleanup_nonces(self, request, queryset):
+        store = DjangoOpenIDStore()
+        count = store.cleanupNonces()
+        self.message_user(request, "%d expired nonces removed" % count)
+    cleanup_nonces.short_description = "Clean up expired nonces"
+
+admin.site.register(Nonce, NonceAdmin)
+
+
+class AssociationAdmin(admin.ModelAdmin):
+    list_display = ('server_url', 'assoc_type')
+    list_filter = ('assoc_type',)
+    search_fields = ('server_url',)
+    actions = ['cleanup_associations']
+
+    def cleanup_associations(self, request, queryset):
+        store = DjangoOpenIDStore()
+        count = store.cleanupAssociations()
+        self.message_user(request, "%d expired associations removed" % count)
+    cleanup_associations.short_description = "Clean up expired associations"
+
+admin.site.register(Association, AssociationAdmin)
+
+
+class UserOpenIDAdmin(admin.ModelAdmin):
+    list_display = ('user', 'claimed_id')
+    search_fields = ('claimed_id',)
+
+admin.site.register(UserOpenID, UserOpenIDAdmin)
+
+
+# Support for allowing openid authentication for /admin (django.contrib.admin)
+if getattr(settings, 'OPENID_USE_AS_ADMIN_LOGIN', False):
+    from django.http import HttpResponseRedirect
+    from django_openid_auth import views
+
+    def _openid_login(self, request, error_message='', extra_context=None):
+        if request.user.is_authenticated():
+            if not request.user.is_staff:
+                return views.render_failure(
+                    request, "User %s does not have admin access."
+                    % request.user.username)
+            return views.render_failure(
+                request, "Unknown Error: %s" % error_message)
+        else:
+            # Redirect to openid login path,
+            return HttpResponseRedirect(
+                settings.LOGIN_URL + "?next=" + request.get_full_path())
+
+    # Overide the standard admin login form.
+    admin.sites.AdminSite.display_login_form = _openid_login
diff --git a/lib/django_openid_auth/auth.py b/lib/django_openid_auth/auth.py
new file mode 100644
index 0000000..047c63a
--- /dev/null
+++ b/lib/django_openid_auth/auth.py
@@ -0,0 +1,183 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+"""Glue between OpenID and django.contrib.auth."""
+
+__metaclass__ = type
+
+from django.conf import settings
+from django.contrib.auth.models import User, Group
+from openid.consumer.consumer import SUCCESS
+from openid.extensions import sreg
+
+from django_openid_auth import teams
+from django_openid_auth.models import UserOpenID
+
+
+class IdentityAlreadyClaimed(Exception):
+    pass
+
+
+class OpenIDBackend:
+    """A django.contrib.auth backend that authenticates the user based on
+    an OpenID response."""
+
+    def get_user(self, user_id):
+        try:
+            return User.objects.get(pk=user_id)
+        except User.DoesNotExist:
+            return None
+
+    def authenticate(self, **kwargs):
+        """Authenticate the user based on an OpenID response."""
+        # Require that the OpenID response be passed in as a keyword
+        # argument, to make sure we don't match the username/password
+        # calling conventions of authenticate.
+
+        openid_response = kwargs.get('openid_response')
+        if openid_response is None:
+            return None
+
+        if openid_response.status != SUCCESS:
+            return None
+
+        user = None
+        try:
+            user_openid = UserOpenID.objects.get(
+                claimed_id__exact=openid_response.identity_url)
+        except UserOpenID.DoesNotExist:
+            if getattr(settings, 'OPENID_CREATE_USERS', False):
+                user = self.create_user_from_openid(openid_response)
+        else:
+            user = user_openid.user
+
+        if user is None:
+            return None
+
+        if getattr(settings, 'OPENID_UPDATE_DETAILS_FROM_SREG', False):
+            sreg_response = sreg.SRegResponse.fromSuccessResponse(
+                openid_response)
+            if sreg_response:
+                self.update_user_details_from_sreg(user, sreg_response)
+
+        teams_response = teams.TeamsResponse.fromSuccessResponse(
+            openid_response)
+        if teams_response:
+            self.update_groups_from_teams(user, teams_response)
+
+        return user
+
+    def create_user_from_openid(self, openid_response):
+        sreg_response = sreg.SRegResponse.fromSuccessResponse(openid_response)
+        if sreg_response:
+            nickname = sreg_response.get('nickname', 'openiduser')
+            email = sreg_response.get('email', '')
+        else:
+            nickname = 'openiduser'
+            email = ''
+
+        # Pick a username for the user based on their nickname,
+        # checking for conflicts.
+        i = 1
+        while True:
+            username = nickname
+            if i > 1:
+                username += str(i)
+            try:
+                User.objects.get(username__exact=username)
+            except User.DoesNotExist:
+                break
+            i += 1
+
+        user = User.objects.create_user(username, email, password=None)
+
+        if sreg_response:
+            self.update_user_details_from_sreg(user, sreg_response)
+
+        self.associate_openid(user, openid_response)
+        return user
+
+    def associate_openid(self, user, openid_response):
+        """Associate an OpenID with a user account."""
+        # Check to see if this OpenID has already been claimed.
+        try:
+            user_openid = UserOpenID.objects.get(
+                claimed_id__exact=openid_response.identity_url)
+        except UserOpenID.DoesNotExist:
+            user_openid = UserOpenID(
+                user=user,
+                claimed_id=openid_response.identity_url,
+                display_id=openid_response.endpoint.getDisplayIdentifier())
+            user_openid.save()
+        else:
+            if user_openid.user != user:
+                raise IdentityAlreadyClaimed(
+                    "The identity %s has already been claimed"
+                    % openid_response.identity_url)
+
+        return user_openid
+
+    def update_user_details_from_sreg(self, user, sreg_response):
+        fullname = sreg_response.get('fullname')
+        if fullname:
+            # Do our best here ...
+            if ' ' in fullname:
+                user.first_name, user.last_name = fullname.rsplit(None, 1)
+            else:
+                user.first_name = u''
+                user.last_name = fullname
+
+        email = sreg_response.get('email')
+        if email:
+            user.email = email
+        user.save()
+
+    def update_groups_from_teams(self, user, teams_response):
+        teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False)
+        teams_mapping_auto_blacklist = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO_BLACKLIST', [])
+        teams_mapping = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING', {})
+        if teams_mapping_auto:
+            #ignore teams_mapping. use all django-groups
+            teams_mapping = dict()
+            all_groups = Group.objects.exclude(name__in=teams_mapping_auto_blacklist)
+            for group in all_groups:
+                teams_mapping[group.name] = group.name
+
+        if len(teams_mapping) == 0:
+            return
+
+        current_groups = set(user.groups.filter(
+                name__in=teams_mapping.values()))
+        desired_groups = set(Group.objects.filter(
+                name__in=[teams_mapping[lp_team]
+                          for lp_team in teams_response.is_member
+                          if lp_team in teams_mapping]))
+        for group in current_groups - desired_groups:
+            user.groups.remove(group)
+        for group in desired_groups - current_groups:
+            user.groups.add(group)
diff --git a/lib/django_openid_auth/forms.py b/lib/django_openid_auth/forms.py
new file mode 100644
index 0000000..d3fd112
--- /dev/null
+++ b/lib/django_openid_auth/forms.py
@@ -0,0 +1,87 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2007 Simon Willison
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from django import forms
+from django.contrib.auth.admin import UserAdmin
+from django.contrib.auth.forms import UserChangeForm
+from django.contrib.auth.models import Group
+from django.utils.translation import ugettext as _
+from django.conf import settings
+
+from openid.yadis import xri
+
+
+def teams_new_unicode(self):
+    """
+    Replacement for Group.__unicode__()
+    Calls original method to chain results
+    """
+    name = self.unicode_before_teams()
+    teams_mapping = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING', {})
+    group_teams = [t for t in teams_mapping if teams_mapping[t] == self.name]
+    if len(group_teams) > 0:
+        return "%s -> %s" % (name, ", ".join(group_teams))
+    else:
+        return name
+Group.unicode_before_teams = Group.__unicode__
+Group.__unicode__ = teams_new_unicode
+
+
+class UserChangeFormWithTeamRestriction(UserChangeForm):
+    """
+    Extends UserChangeForm to add teams awareness to the user admin form
+    """
+    def clean_groups(self):
+        data = self.cleaned_data['groups']
+        teams_mapping = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING', {})
+        known_teams = teams_mapping.values()
+        user_groups = self.instance.groups.all()
+        for group in data:
+            if group.name in known_teams and group not in user_groups:
+                raise forms.ValidationError("""The group %s is mapped to an
+                    external team.  You cannot assign it manually.""" % group.name)
+        return data
+UserAdmin.form = UserChangeFormWithTeamRestriction
+
+
+class OpenIDLoginForm(forms.Form):
+    openid_identifier = forms.CharField(
+        max_length=255,
+        widget=forms.TextInput(attrs={'class': 'required openid'}))
+
+    def clean_openid_identifier(self):
+        if 'openid_identifier' in self.cleaned_data:
+            openid_identifier = self.cleaned_data['openid_identifier']
+            if xri.identifierScheme(openid_identifier) == 'XRI' and getattr(
+                settings, 'OPENID_DISALLOW_INAMES', False
+                ):
+                raise forms.ValidationError(_('i-names are not supported'))
+            return self.cleaned_data['openid_identifier']
+
+
diff --git a/lib/django_openid_auth/management/__init__.py b/lib/django_openid_auth/management/__init__.py
new file mode 100644
index 0000000..cb7e18f
--- /dev/null
+++ b/lib/django_openid_auth/management/__init__.py
@@ -0,0 +1,27 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/django_openid_auth/management/commands/__init__.py b/lib/django_openid_auth/management/commands/__init__.py
new file mode 100644
index 0000000..cb7e18f
--- /dev/null
+++ b/lib/django_openid_auth/management/commands/__init__.py
@@ -0,0 +1,27 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
diff --git a/lib/django_openid_auth/management/commands/openid_cleanup.py b/lib/django_openid_auth/management/commands/openid_cleanup.py
new file mode 100644
index 0000000..f717dff
--- /dev/null
+++ b/lib/django_openid_auth/management/commands/openid_cleanup.py
@@ -0,0 +1,39 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from django.core.management.base import NoArgsCommand
+
+from django_openid_auth.store import DjangoOpenIDStore
+
+
+class Command(NoArgsCommand):
+    help = 'Clean up stale OpenID associations and nonces'
+
+    def handle_noargs(self, **options):
+        store = DjangoOpenIDStore()
+        store.cleanup()
diff --git a/lib/django_openid_auth/models.py b/lib/django_openid_auth/models.py
new file mode 100644
index 0000000..a9af98f
--- /dev/null
+++ b/lib/django_openid_auth/models.py
@@ -0,0 +1,58 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2007 Simon Willison
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from django.contrib.auth.models import User
+from django.db import models
+
+
+class Nonce(models.Model):
+    server_url = models.CharField(max_length=2047)
+    timestamp = models.IntegerField()
+    salt = models.CharField(max_length=40)
+
+    def __unicode__(self):
+        return u"Nonce: %s, %s" % (self.server_url, self.salt)
+
+
+class Association(models.Model):
+    server_url = models.TextField(max_length=2047)
+    handle = models.CharField(max_length=255)
+    secret = models.TextField(max_length=255) # Stored base64 encoded
+    issued = models.IntegerField()
+    lifetime = models.IntegerField()
+    assoc_type = models.TextField(max_length=64)
+
+    def __unicode__(self):
+        return u"Association: %s, %s" % (self.server_url, self.handle)
+
+
+class UserOpenID(models.Model):
+    user = models.ForeignKey(User)
+    claimed_id = models.TextField(max_length=2047, unique=True)
+    display_id = models.TextField(max_length=2047)
diff --git a/lib/django_openid_auth/store.py b/lib/django_openid_auth/store.py
new file mode 100644
index 0000000..dab547f
--- /dev/null
+++ b/lib/django_openid_auth/store.py
@@ -0,0 +1,131 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2007 Simon Willison
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import base64
+import time
+
+from openid.association import Association as OIDAssociation
+from openid.store.interface import OpenIDStore
+from openid.store.nonce import SKEW
+
+from django_openid_auth.models import Association, Nonce
+
+
+class DjangoOpenIDStore(OpenIDStore):
+    def __init__(self):
+        self.max_nonce_age = 6 * 60 * 60 # Six hours
+
+    def storeAssociation(self, server_url, association):
+        try:
+            assoc = Association.objects.get(
+                server_url=server_url, handle=association.handle)
+        except Association.DoesNotExist:
+            assoc = Association(
+                server_url=server_url,
+                handle=association.handle,
+                secret=base64.encodestring(association.secret),
+                issued=association.issued,
+                lifetime=association.lifetime,
+                assoc_type=association.assoc_type)
+        else:
+            assoc.secret = base64.encodestring(association.secret)
+            assoc.issued = association.issued
+            assoc.lifetime = association.lifetime
+            assoc.assoc_type = association.assoc_type
+        assoc.save()
+
+    def getAssociation(self, server_url, handle=None):
+        assocs = []
+        if handle is not None:
+            assocs = Association.objects.filter(
+                server_url=server_url, handle=handle)
+        else:
+            assocs = Association.objects.filter(server_url=server_url)
+        associations = []
+        expired = []
+        for assoc in assocs:
+            association = OIDAssociation(
+                assoc.handle, base64.decodestring(assoc.secret), assoc.issued,
+                assoc.lifetime, assoc.assoc_type
+            )
+            if association.getExpiresIn() == 0:
+                expired.append(assoc)
+            else:
+                associations.append((association.issued, association))
+        for assoc in expired:
+            assoc.delete()
+        if not associations:
+            return None
+        associations.sort()
+        return associations[-1][1]
+
+    def removeAssociation(self, server_url, handle):
+        assocs = list(Association.objects.filter(
+            server_url=server_url, handle=handle))
+        assocs_exist = len(assocs) > 0
+        for assoc in assocs:
+            assoc.delete()
+        return assocs_exist
+
+    def useNonce(self, server_url, timestamp, salt):
+        if abs(timestamp - time.time()) > SKEW:
+            return False
+
+        try:
+            ononce = Nonce.objects.get(
+                server_url__exact=server_url,
+                timestamp__exact=timestamp,
+                salt__exact=salt)
+        except Nonce.DoesNotExist:
+            ononce = Nonce(
+                server_url=server_url,
+                timestamp=timestamp,
+                salt=salt)
+            ononce.save()
+            return True
+
+        return False
+
+    def cleanupNonces(self, _now=None):
+        if _now is None:
+            _now = int(time.time())
+        expired = Nonce.objects.filter(timestamp__lt=_now - SKEW)
+        count = expired.count()
+        if count:
+            expired.delete()
+        return count
+
+    def cleanupAssociations(self):
+        now = int(time.time())
+        expired = Association.objects.extra(
+            where=['issued + lifetime < %d' % now])
+        count = expired.count()
+        if count:
+            expired.delete()
+        return count
diff --git a/lib/django_openid_auth/teams.py b/lib/django_openid_auth/teams.py
new file mode 100644
index 0000000..b5744e9
--- /dev/null
+++ b/lib/django_openid_auth/teams.py
@@ -0,0 +1,411 @@
+# Launchpad OpenID Teams Extension support for python-openid
+#
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+"""Team membership support for Launchpad.
+
+The primary form of communication between the RP and Launchpad is an
+OpenID authentication request. Our solution is to piggyback a team
+membership test onto this interaction.
+
+As part of an OpenID authentication request, the RP includes the
+following fields:
+
+  openid.ns.lp:
+    An OpenID 2.0 namespace URI for the extension. It is not strictly
+    required for 1.1 requests, but including it is good for forward
+    compatibility.
+
+    It must be set to: http://ns.launchpad.net/2007/openid-teams
+
+  openid.lp.query_membership:
+    A comma separated list of Launchpad team names that the RP is
+    interested in.
+
+As part of the positive assertion OpenID response, the following field
+will be provided:
+
+  openid.ns.lp:
+    (as above)
+
+  openid.lp.is_member:
+    A comma separated list of teams that the user is actually a member
+    of. The list may be limited to those teams mentioned in the
+    request.
+
+    This field must be included in the response signature in order to
+    be considered valid (as the response is bounced through the user's
+    web browser, an unsigned value could be modified).
+
+ since: 2.1.1
+"""
+
+from openid.message import registerNamespaceAlias, \
+     NamespaceAliasRegistrationError
+from openid.extension import Extension
+from openid import oidutil
+
+try:
+    basestring #pylint:disable-msg=W0104
+except NameError:
+    # For Python 2.2
+    basestring = (str, unicode) #pylint:disable-msg=W0622
+
+__all__ = [
+    'TeamsRequest',
+    'TeamsResponse',
+    'ns_uri',
+    'supportsTeams',
+    ]
+
+ns_uri = 'http://ns.launchpad.net/2007/openid-teams'
+
+try:
+    registerNamespaceAlias(ns_uri, 'lp')
+except NamespaceAliasRegistrationError, e:
+    oidutil.log('registerNamespaceAlias(%r, %r) failed: %s' % (ns_uri,
+                                                               'lp', str(e),))
+
+def supportsTeams(endpoint):
+    """Does the given endpoint advertise support for Launchpad Teams?
+
+    @param endpoint: The endpoint object as returned by OpenID discovery
+    @type endpoint: openid.consumer.discover.OpenIDEndpoint
+
+    @returns: Whether an lp type was advertised by the endpoint
+    @rtype: bool
+    """
+    return endpoint.usesExtension(ns_uri)
+
+class TeamsNamespaceError(ValueError):
+    """The Launchpad teams namespace was not found and could not
+    be created using the expected name (there's another extension
+    using the name 'lp')
+
+    This is not I{illegal}, for OpenID 2, although it probably
+    indicates a problem, since it's not expected that other extensions
+    will re-use the alias that is in use for OpenID 1.
+
+    If this is an OpenID 1 request, then there is no recourse. This
+    should not happen unless some code has modified the namespaces for
+    the message that is being processed.
+    """
+
+def getTeamsNS(message):
+    """Extract the Launchpad teams namespace URI from the given
+    OpenID message.
+
+    @param message: The OpenID message from which to parse Launchpad
+        teams. This may be a request or response message.
+    @type message: C{L{openid.message.Message}}
+
+    @returns: the lp namespace URI for the supplied message. The
+        message may be modified to define a Launchpad teams
+        namespace.
+    @rtype: C{str}
+
+    @raise ValueError: when using OpenID 1 if the message defines
+        the 'lp' alias to be something other than a Launchpad
+        teams type.
+    """
+    # See if there exists an alias for the Launchpad teams type.
+    alias = message.namespaces.getAlias(ns_uri)
+    if alias is None:
+        # There is no alias, so try to add one. (OpenID version 1)
+        try:
+            message.namespaces.addAlias(ns_uri, 'lp')
+        except KeyError, why:
+            # An alias for the string 'lp' already exists, but it's
+            # defined for something other than Launchpad teams
+            raise TeamsNamespaceError(why[0])
+
+    # we know that ns_uri defined, because it's defined in the
+    # else clause of the loop as well, so disable the warning
+    return ns_uri #pylint:disable-msg=W0631
+
+class TeamsRequest(Extension):
+    """An object to hold the state of a Launchpad teams request.
+
+    @ivar query_membership: A comma separated list of Launchpad team
+        names that the RP is interested in.
+    @type required: [str]
+
+    @group Consumer: requestField, requestTeams, getExtensionArgs, addToOpenIDRequest
+    @group Server: fromOpenIDRequest, parseExtensionArgs
+    """
+
+    ns_alias = 'lp'
+
+    def __init__(self, query_membership=None, lp_ns_uri=ns_uri):
+        """Initialize an empty Launchpad teams request"""
+        Extension.__init__(self)
+        self.query_membership = []
+        self.ns_uri = lp_ns_uri
+
+        if query_membership:
+            self.requestTeams(query_membership)
+
+    # Assign getTeamsNS to a static method so that it can be
+    # overridden for testing.
+    _getTeamsNS = staticmethod(getTeamsNS)
+
+    def fromOpenIDRequest(cls, request):
+        """Create a Launchpad teams request that contains the
+        fields that were requested in the OpenID request with the
+        given arguments
+
+        @param request: The OpenID request
+        @type request: openid.server.CheckIDRequest
+
+        @returns: The newly created Launchpad teams request
+        @rtype: C{L{TeamsRequest}}
+        """
+        self = cls()
+
+        # Since we're going to mess with namespace URI mapping, don't
+        # mutate the object that was passed in.
+        message = request.message.copy()
+
+        self.ns_uri = self._getTeamsNS(message)
+        args = message.getArgs(self.ns_uri)
+        self.parseExtensionArgs(args)
+
+        return self
+
+    fromOpenIDRequest = classmethod(fromOpenIDRequest)
+
+    def parseExtensionArgs(self, args, strict=False):
+        """Parse the unqualified Launchpad teams request
+        parameters and add them to this object.
+
+        This method is essentially the inverse of
+        C{L{getExtensionArgs}}. This method restores the serialized
+        Launchpad teams request fields.
+
+        If you are extracting arguments from a standard OpenID
+        checkid_* request, you probably want to use C{L{fromOpenIDRequest}},
+        which will extract the lp namespace and arguments from the
+        OpenID request. This method is intended for cases where the
+        OpenID server needs more control over how the arguments are
+        parsed than that method provides.
+
+        >>> args = message.getArgs(ns_uri)
+        >>> request.parseExtensionArgs(args)
+
+        @param args: The unqualified Launchpad teams arguments
+        @type args: {str:str}
+
+        @param strict: Whether requests with fields that are not
+            defined in the Launchpad teams specification should be
+            tolerated (and ignored)
+        @type strict: bool
+
+        @returns: None; updates this object
+        """
+        items = args.get('query_membership')
+        if items:
+            for team_name in items.split(','):
+                try:
+                    self.requestTeam(team_name, strict)
+                except ValueError:
+                    if strict:
+                        raise
+
+    def allRequestedTeams(self):
+        """A list of all of the Launchpad teams that were
+        requested.
+
+        @rtype: [str]
+        """
+        return self.query_membership
+
+    def wereTeamsRequested(self):
+        """Have any Launchpad teams been requested?
+
+        @rtype: bool
+        """
+        return bool(self.allRequestedTeams())
+
+    def __contains__(self, team_name):
+        """Was this team in the request?"""
+        return team_name in self.query_membership
+
+    def requestTeam(self, team_name, strict=False):
+        """Request the specified team from the OpenID user
+
+        @param team_name: the unqualified Launchpad team name
+        @type team_name: str
+
+        @param strict: whether to raise an exception when a team is
+            added to a request more than once
+
+        @raise ValueError: when strict is set and the team was
+            requested more than once
+        """
+        if strict:
+            if team_name in self.query_membership:
+                raise ValueError('That team has already been requested')
+        else:
+            if team_name in self.query_membership:
+                return
+
+        self.query_membership.append(team_name)
+
+    def requestTeams(self, query_membership, strict=False):
+        """Add the given list of teams to the request
+
+        @param query_membership: The Launchpad teams request
+        @type query_membership: [str]
+
+        @raise ValueError: when a team requested is not a string
+            or strict is set and a team was requested more than once
+        """
+        if isinstance(query_membership, basestring):
+            raise TypeError('Teams should be passed as a list of '
+                            'strings (not %r)' % (type(query_membership),))
+
+        for team_name in query_membership:
+            self.requestTeam(team_name, strict=strict)
+
+    def getExtensionArgs(self):
+        """Get a dictionary of unqualified Launchpad teams
+        arguments representing this request.
+
+        This method is essentially the inverse of
+        C{L{parseExtensionArgs}}. This method serializes the Launchpad
+        teams request fields.
+
+        @rtype: {str:str}
+        """
+        args = {}
+
+        if self.query_membership:
+            args['query_membership'] = ','.join(self.query_membership)
+
+        return args
+
+class TeamsResponse(Extension):
+    """Represents the data returned in a Launchpad teams response
+    inside of an OpenID C{id_res} response. This object will be
+    created by the OpenID server, added to the C{id_res} response
+    object, and then extracted from the C{id_res} message by the
+    Consumer.
+
+    @ivar data: The Launchpad teams data, an array.
+
+    @ivar ns_uri: The URI under which the Launchpad teams data was
+        stored in the response message.
+
+    @group Server: extractResponse
+    @group Consumer: fromSuccessResponse
+    @group Read-only dictionary interface: keys, iterkeys, items, iteritems,
+        __iter__, get, __getitem__, keys, has_key
+    """
+
+    ns_alias = 'lp'
+
+    def __init__(self, is_member=None, lp_ns_uri=ns_uri):
+        Extension.__init__(self)
+        if is_member is None:
+            self.is_member = []
+        else:
+            self.is_member = is_member
+
+        self.ns_uri = lp_ns_uri
+
+    def addTeam(self, team_name):
+        if team_name not in self.is_member:
+            self.is_member.append(team_name)
+
+    def extractResponse(cls, request, is_member_str):
+        """Take a C{L{TeamsRequest}} and a list of Launchpad
+        team values and create a C{L{TeamsResponse}}
+        object containing that data.
+
+        @param request: The Launchpad teams request object
+        @type request: TeamsRequest
+
+        @param is_member: The Launchpad teams data for this
+            response, as a list of strings.
+        @type is_member: {str:str}
+
+        @returns: a Launchpad teams response object
+        @rtype: TeamsResponse
+        """
+        self = cls()
+        self.ns_uri = request.ns_uri
+        self.is_member = is_member_str.split(',')
+        return self
+
+    extractResponse = classmethod(extractResponse)
+
+    # Assign getTeamsNS to a static method so that it can be
+    # overridden for testing
+    _getTeamsNS = staticmethod(getTeamsNS)
+
+    def fromSuccessResponse(cls, success_response, signed_only=True):
+        """Create a C{L{TeamsResponse}} object from a successful OpenID
+        library response
+        (C{L{openid.consumer.consumer.SuccessResponse}}) response
+        message
+
+        @param success_response: A SuccessResponse from consumer.complete()
+        @type success_response: C{L{openid.consumer.consumer.SuccessResponse}}
+
+        @param signed_only: Whether to process only data that was
+            signed in the id_res message from the server.
+        @type signed_only: bool
+
+        @rtype: TeamsResponse
+        @returns: A Launchpad teams response containing the data
+            that was supplied with the C{id_res} response.
+        """
+        self = cls()
+        self.ns_uri = self._getTeamsNS(success_response.message)
+        if signed_only:
+            args = success_response.getSignedNS(self.ns_uri)
+        else:
+            args = success_response.message.getArgs(self.ns_uri)
+
+        if "is_member" in args:
+            is_member_str = args["is_member"]
+            self.is_member = is_member_str.split(',')
+            #self.is_member = args["is_member"]
+
+        return self
+
+    fromSuccessResponse = classmethod(fromSuccessResponse)
+
+    def getExtensionArgs(self):
+        """Get the fields to put in the Launchpad teams namespace
+        when adding them to an id_res message.
+
+        @see: openid.extension
+        """
+        ns_args = {'is_member': ','.join(self.is_member),}
+        return ns_args
+
diff --git a/lib/django_openid_auth/templates/openid/failure.html b/lib/django_openid_auth/templates/openid/failure.html
new file mode 100644
index 0000000..87839ab
--- /dev/null
+++ b/lib/django_openid_auth/templates/openid/failure.html
@@ -0,0 +1,13 @@
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+  "http://www.w3.org/TR/html4/strict.dtd";>
+<html>
+<head>
+  <title>OpenID failed</title>
+</head>
+<body>
+<h1>OpenID failed</h1>
+
+<p>{{ message|escape }}</p>
+
+</body>
+</html>
\ No newline at end of file
diff --git a/lib/django_openid_auth/templates/openid/login.html b/lib/django_openid_auth/templates/openid/login.html
new file mode 100644
index 0000000..712d047
--- /dev/null
+++ b/lib/django_openid_auth/templates/openid/login.html
@@ -0,0 +1,44 @@
+{% load i18n %}
+
+<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN"
+  "http://www.w3.org/TR/html4/strict.dtd";>
+<html>
+<head>
+<title>Sign in with your OpenID</title>
+<style type="text/css">
+input.openid {
+  background: url({% url openid-logo %}) no-repeat; 
+  background-position: 0 50%;
+  padding-left: 16px;
+}
+</style>
+</head>
+<body>
+<h1>Sign in with your OpenID</h1>
+{% if form.errors %}
+<p class="errors">{% trans "Please correct errors below:" %}<br />
+	{% if form.openid_identifier.errors %} 
+		<span class="error">{{ form.openid_identifier.errors|join:", " }}</span>
+	{% endif %}
+	{% if form.next.errors %} 
+		<span class="error">{{ form.next.errors|join:", " }}</span>
+	{% endif %}
+</p>
+{% endif %}
+<form name="fopenid" action="{{ action }}" method="post">
+	<fieldset>
+		<legend>{% trans "Sign In Using Your OpenID" %}</legend>
+        <div class="form-row">
+            <label for="id_openid_identifier">{% trans "OpenID:" %}</label><br />
+            {{ form.openid_identifier }}
+        </div>
+        <div class="submit-row "><input name="bsignin" type="submit" value="{% trans "Sign in" %}"></div>
+
+ {% if next %}
+	<input type="hidden" name="next" value="{{ next }}" />
+ {% endif %}
+
+	</fieldset>
+</form>	
+</body>
+</html>
diff --git a/lib/django_openid_auth/tests/__init__.py b/lib/django_openid_auth/tests/__init__.py
new file mode 100644
index 0000000..a324e69
--- /dev/null
+++ b/lib/django_openid_auth/tests/__init__.py
@@ -0,0 +1,37 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import unittest
+
+
+def suite():
+    suite = unittest.TestSuite()
+    for name in ['test_store', 'test_views']:
+        mod = __import__('%s.%s' % (__name__, name), {}, {}, ['suite'])
+        suite.addTest(mod.suite())
+    return suite
diff --git a/lib/django_openid_auth/tests/test_store.py b/lib/django_openid_auth/tests/test_store.py
new file mode 100644
index 0000000..588c8fa
--- /dev/null
+++ b/lib/django_openid_auth/tests/test_store.py
@@ -0,0 +1,193 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import time
+import unittest
+
+from django.test import TestCase
+from openid.association import Association as OIDAssociation
+from openid.store.nonce import SKEW
+
+from django_openid_auth.models import Association, Nonce
+from django_openid_auth.store import DjangoOpenIDStore
+
+
+class OpenIDStoreTests(TestCase):
+
+    def setUp(self):
+        super(OpenIDStoreTests, self).setUp()
+        self.store = DjangoOpenIDStore()
+
+    def test_storeAssociation(self):
+        assoc = OIDAssociation('handle', 'secret', 42, 600, 'HMAC-SHA1')
+        self.store.storeAssociation('server-url', assoc)
+
+        dbassoc = Association.objects.get(
+            server_url='server-url', handle='handle')
+        self.assertEquals(dbassoc.server_url, 'server-url')
+        self.assertEquals(dbassoc.handle, 'handle')
+        self.assertEquals(dbassoc.secret, 'secret'.encode('base-64'))
+        self.assertEquals(dbassoc.issued, 42)
+        self.assertEquals(dbassoc.lifetime, 600)
+        self.assertEquals(dbassoc.assoc_type, 'HMAC-SHA1')
+
+    def test_storeAssociation_update_existing(self):
+        assoc = OIDAssociation('handle', 'secret', 42, 600, 'HMAC-SHA1')
+        self.store.storeAssociation('server-url', assoc)
+
+        # Now update the association with new information.
+        assoc = OIDAssociation('handle', 'secret2', 420, 900, 'HMAC-SHA256')
+        self.store.storeAssociation('server-url', assoc)
+        dbassoc = Association.objects.get(
+            server_url='server-url', handle='handle')
+        self.assertEqual(dbassoc.secret, 'secret2'.encode('base-64'))
+        self.assertEqual(dbassoc.issued, 420)
+        self.assertEqual(dbassoc.lifetime, 900)
+        self.assertEqual(dbassoc.assoc_type, 'HMAC-SHA256')
+
+    def test_getAssociation(self):
+        timestamp = int(time.time())
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle', 'secret', timestamp, 600,
+                                         'HMAC-SHA1'))
+        assoc = self.store.getAssociation('server-url', 'handle')
+        self.assertTrue(isinstance(assoc, OIDAssociation))
+
+        self.assertEquals(assoc.handle, 'handle')
+        self.assertEquals(assoc.secret, 'secret')
+        self.assertEquals(assoc.issued, timestamp)
+        self.assertEquals(assoc.lifetime, 600)
+        self.assertEquals(assoc.assoc_type, 'HMAC-SHA1')
+
+    def test_getAssociation_unknown(self):
+        assoc = self.store.getAssociation('server-url', 'unknown')
+        self.assertEquals(assoc, None)
+
+    def test_getAssociation_expired(self):
+        lifetime = 600
+        timestamp = int(time.time()) - 2 * lifetime
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle', 'secret', timestamp,
+                                         lifetime, 'HMAC-SHA1'))
+
+        # The association is not returned, and is removed from the database.
+        assoc = self.store.getAssociation('server-url', 'handle')
+        self.assertEquals(assoc, None)
+        self.assertRaises(Association.DoesNotExist, Association.objects.get,
+                          server_url='server-url', handle='handle')
+
+    def test_getAssociation_no_handle(self):
+        timestamp = int(time.time())
+
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle1', 'secret', timestamp + 1,
+                                         600, 'HMAC-SHA1'))
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle2', 'secret', timestamp,
+                                         600, 'HMAC-SHA1'))
+
+        # The newest handle is returned.
+        assoc = self.store.getAssociation('server-url', None)
+        self.assertNotEquals(assoc, None)
+        self.assertEquals(assoc.handle, 'handle1')
+        self.assertEquals(assoc.issued, timestamp + 1)
+
+    def test_removeAssociation(self):
+        timestamp = int(time.time())
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle', 'secret', timestamp, 600,
+                                         'HMAC-SHA1'))
+        self.assertEquals(
+            self.store.removeAssociation('server-url', 'handle'), True)
+        self.assertEquals(
+            self.store.getAssociation('server-url', 'handle'), None)
+
+    def test_removeAssociation_unknown(self):
+        self.assertEquals(
+            self.store.removeAssociation('server-url', 'unknown'), False)
+
+    def test_useNonce(self):
+        timestamp = time.time()
+        # The nonce can only be used once.
+        self.assertEqual(
+            self.store.useNonce('server-url', timestamp, 'salt'), True)
+        self.assertEqual(
+            self.store.useNonce('server-url', timestamp, 'salt'), False)
+        self.assertEqual(
+            self.store.useNonce('server-url', timestamp, 'salt'), False)
+
+    def test_useNonce_expired(self):
+        timestamp = time.time() - 2 * SKEW
+        self.assertEqual(
+            self.store.useNonce('server-url', timestamp, 'salt'), False)
+
+    def test_useNonce_future(self):
+        timestamp = time.time() + 2 * SKEW
+        self.assertEqual(
+            self.store.useNonce('server-url', timestamp, 'salt'), False)
+
+    def test_cleanupNonces(self):
+        timestamp = time.time()
+        self.assertEqual(
+            self.store.useNonce('server1', timestamp, 'salt1'), True)
+        self.assertEqual(
+            self.store.useNonce('server2', timestamp, 'salt2'), True)
+        self.assertEqual(
+            self.store.useNonce('server3', timestamp, 'salt3'), True)
+        self.assertEqual(Nonce.objects.count(), 3)
+
+        self.assertEqual(
+            self.store.cleanupNonces(_now=timestamp + 2 * SKEW), 3)
+        self.assertEqual(Nonce.objects.count(), 0)
+
+        # The nonces have now been cleared:
+        self.assertEqual(
+            self.store.useNonce('server1', timestamp, 'salt1'), True)
+        self.assertEqual(
+            self.store.cleanupNonces(_now=timestamp + 2 * SKEW), 1)
+        self.assertEqual(
+            self.store.cleanupNonces(_now=timestamp + 2 * SKEW), 0)
+
+    def test_cleanupAssociations(self):
+        timestamp = int(time.time()) - 100
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle1', 'secret', timestamp,
+                                         50, 'HMAC-SHA1'))
+        self.store.storeAssociation(
+            'server-url', OIDAssociation('handle2', 'secret', timestamp,
+                                         200, 'HMAC-SHA1'))
+
+        self.assertEquals(self.store.cleanupAssociations(), 1)
+
+        # The second (non-expired) association is left behind.
+        self.assertNotEqual(self.store.getAssociation('server-url', 'handle2'),
+                            None)
+
+
+def suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/lib/django_openid_auth/tests/test_views.py b/lib/django_openid_auth/tests/test_views.py
new file mode 100644
index 0000000..0c6c546
--- /dev/null
+++ b/lib/django_openid_auth/tests/test_views.py
@@ -0,0 +1,423 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import cgi
+import re
+import time
+import unittest
+
+from django.conf import settings
+from django.contrib.auth.models import User, Group
+from django.test import TestCase
+from openid.extensions.sreg import SRegRequest, SRegResponse
+from openid.fetchers import (
+    HTTPFetcher, HTTPFetchingError, HTTPResponse, setDefaultFetcher)
+from openid.oidutil import importElementTree
+from openid.server.server import BROWSER_REQUEST_MODES, Server
+from openid.store.memstore import MemoryStore
+
+from django_openid_auth import teams
+from django_openid_auth.models import UserOpenID
+from django_openid_auth.views import sanitise_redirect_url
+
+
+ET = importElementTree()
+
+
+class StubOpenIDProvider(HTTPFetcher):
+
+    def __init__(self, base_url):
+        self.store = MemoryStore()
+        self.identity_url = base_url + 'identity'
+        self.localid_url = base_url + 'localid'
+        self.endpoint_url = base_url + 'endpoint'
+        self.server = Server(self.store, self.endpoint_url)
+        self.last_request = None
+
+    def fetch(self, url, body=None, headers=None):
+        if url == self.identity_url:
+            # Serve an XRDS document directly, which is the 
+            return HTTPResponse(
+                url, 200, {'content-type': 'application/xrds+xml'}, """\
+<?xml version="1.0"?>
+<xrds:XRDS
+    xmlns="xri://$xrd*($v*2.0)"
+    xmlns:xrds="xri://$xrds">
+  <XRD>
+    <Service priority="0">
+      <Type>http://specs.openid.net/auth/2.0/signon</Type>
+      <URI>%s</URI>
+      <LocalID>%s</LocalID>
+    </Service>
+  </XRD>
+</xrds:XRDS>
+""" % (self.endpoint_url, self.localid_url))
+        elif url.startswith(self.endpoint_url):
+            # Gather query parameters
+            query = {}
+            if '?' in url:
+                query.update(cgi.parse_qsl(url.split('?', 1)[1]))
+            if body is not None:
+                query.update(cgi.parse_qsl(body))
+            self.last_request = self.server.decodeRequest(query)
+
+            # The browser based requests should not be handled through
+            # the fetcher interface.
+            assert self.last_request.mode not in BROWSER_REQUEST_MODES
+
+            response = self.server.handleRequest(self.last_request)
+            webresponse = self.server.encodeResponse(response)
+            return HTTPResponse(url,  webresponse.code, webresponse.headers,
+                                webresponse.body)
+        else:
+            raise HTTPFetchingError('unknown URL %s' % url)
+
+    def parseFormPost(self, content):
+        """Parse an HTML form post to create an OpenID request."""
+        # Hack to make the javascript XML compliant ...
+        content = content.replace('i < elements.length',
+                                  'i &lt; elements.length')
+        tree = ET.XML(content)
+        form = tree.find('.//form')
+        assert form is not None, 'No form in document'
+        assert form.get('action') == self.endpoint_url, (
+            'Form posts to %s instead of %s' % (form.get('action'),
+                                                self.endpoint_url))
+        query = {}
+        for input in form.findall('input'):
+            if input.get('type') != 'hidden':
+                continue
+            query[input.get('name').encode('UTF-8')] = \
+                input.get('value').encode('UTF-8')
+        self.last_request = self.server.decodeRequest(query)
+        return self.last_request
+
+
+class RelyingPartyTests(TestCase):
+    urls = 'django_openid_auth.tests.urls'
+
+    def setUp(self):
+        super(RelyingPartyTests, self).setUp()
+        self.provider = StubOpenIDProvider('http://example.com/')
+        setDefaultFetcher(self.provider, wrap_exceptions=False)
+
+        self.old_login_redirect_url = getattr(settings, 'LOGIN_REDIRECT_URL', '/accounts/profile/')
+        self.old_create_users = getattr(settings, 'OPENID_CREATE_USERS', False)
+        self.old_update_details = getattr(settings, 'OPENID_UPDATE_DETAILS_FROM_SREG', False)
+        self.old_sso_server_url = getattr(settings, 'OPENID_SSO_SERVER_URL')
+        self.old_teams_map = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING', {})
+        self.old_use_as_admin_login = getattr(settings, 'OPENID_USE_AS_ADMIN_LOGIN', False)
+
+        settings.OPENID_CREATE_USERS = False
+        settings.OPENID_UPDATE_DETAILS_FROM_SREG = False
+        settings.OPENID_SSO_SERVER_URL = None
+        settings.OPENID_LAUNCHPAD_TEAMS_MAPPING = {}
+        settings.OPENID_USE_AS_ADMIN_LOGIN = False
+
+    def tearDown(self):
+        settings.LOGIN_REDIRECT_URL = self.old_login_redirect_url
+        settings.OPENID_CREATE_USERS = self.old_create_users
+        settings.OPENID_UPDATE_DETAILS_FROM_SREG = self.old_update_details
+        settings.OPENID_SSO_SERVER_URL = self.old_sso_server_url
+        settings.OPENID_LAUNCHPAD_TEAMS_MAPPING = self.old_teams_map
+        settings.OPENID_USE_AS_ADMIN_LOGIN = self.old_use_as_admin_login
+
+        setDefaultFetcher(None)
+        super(RelyingPartyTests, self).tearDown()
+
+    def complete(self, openid_response):
+        """Complete an OpenID authentication request."""
+        webresponse = self.provider.server.encodeResponse(openid_response)
+        self.assertEquals(webresponse.code, 302)
+        redirect_to = webresponse.headers['location']
+        self.assertTrue(redirect_to.startswith(
+                'http://testserver/openid/complete/'))
+        return self.client.get('/openid/complete/',
+            dict(cgi.parse_qsl(redirect_to.split('?', 1)[1])))
+
+    def test_login(self):
+        user = User.objects.create_user('someuser', 'someone example com')
+        useropenid = UserOpenID(
+            user=user,
+            claimed_id='http://example.com/identity',
+            display_id='http://example.com/identity')
+        useropenid.save()
+
+        # The login form is displayed:
+        response = self.client.get('/openid/login/')
+        self.assertTemplateUsed(response, 'openid/login.html')
+
+        # Posting in an identity URL begins the authentication request:
+        response = self.client.post('/openid/login/',
+            {'openid_identifier': 'http://example.com/identity',
+             'next': '/getuser/'})
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        openid_request = self.provider.parseFormPost(response.content)
+        self.assertEquals(openid_request.mode, 'checkid_setup')
+        self.assertTrue(openid_request.return_to.startswith(
+                'http://testserver/openid/complete/'))
+
+        # Complete the request.  The user is redirected to the next URL.
+        openid_response = openid_request.answer(True)
+        response = self.complete(openid_response)
+        self.assertRedirects(response, 'http://testserver/getuser/')
+
+        # And they are now logged in:
+        response = self.client.get('/getuser/')
+        self.assertEquals(response.content, 'someuser')
+
+    def test_login_no_next(self):
+        """Logins with no next parameter redirect to LOGIN_REDIRECT_URL."""
+        user = User.objects.create_user('someuser', 'someone example com')
+        useropenid = UserOpenID(
+            user=user,
+            claimed_id='http://example.com/identity',
+            display_id='http://example.com/identity')
+        useropenid.save()
+
+        settings.LOGIN_REDIRECT_URL = '/getuser/'
+        response = self.client.post('/openid/login/',
+            {'openid_identifier': 'http://example.com/identity'})
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        openid_request = self.provider.parseFormPost(response.content)
+        self.assertEquals(openid_request.mode, 'checkid_setup')
+        self.assertTrue(openid_request.return_to.startswith(
+                'http://testserver/openid/complete/'))
+
+        # Complete the request.  The user is redirected to the next URL.
+        openid_response = openid_request.answer(True)
+        response = self.complete(openid_response)
+        self.assertRedirects(
+            response, 'http://testserver' + settings.LOGIN_REDIRECT_URL)
+
+    def test_login_sso(self):
+        settings.OPENID_SSO_SERVER_URL = 'http://example.com/identity'
+        user = User.objects.create_user('someuser', 'someone example com')
+        useropenid = UserOpenID(
+            user=user,
+            claimed_id='http://example.com/identity',
+            display_id='http://example.com/identity')
+        useropenid.save()
+
+        # Requesting the login form immediately begins an
+        # authentication request.
+        response = self.client.get('/openid/login/', {'next': '/getuser/'})
+        self.assertEquals(response.status_code, 200)
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        openid_request = self.provider.parseFormPost(response.content)
+        self.assertEquals(openid_request.mode, 'checkid_setup')
+        self.assertTrue(openid_request.return_to.startswith(
+                'http://testserver/openid/complete/'))
+
+        # Complete the request.  The user is redirected to the next URL.
+        openid_response = openid_request.answer(True)
+        response = self.complete(openid_response)
+        self.assertRedirects(response, 'http://testserver/getuser/')
+
+        # And they are now logged in:
+        response = self.client.get('/getuser/')
+        self.assertEquals(response.content, 'someuser')
+
+    def test_login_create_users(self):
+        settings.OPENID_CREATE_USERS = True
+        # Create a user with the same name as we'll pass back via sreg.
+        User.objects.create_user('someuser', 'someone example com')
+
+        # Posting in an identity URL begins the authentication request:
+        response = self.client.post('/openid/login/',
+            {'openid_identifier': 'http://example.com/identity',
+             'next': '/getuser/'})
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        # Complete the request, passing back some simple registration
+        # data.  The user is redirected to the next URL.
+        openid_request = self.provider.parseFormPost(response.content)
+        sreg_request = SRegRequest.fromOpenIDRequest(openid_request)
+        openid_response = openid_request.answer(True)
+        sreg_response = SRegResponse.extractResponse(
+            sreg_request, {'nickname': 'someuser', 'fullname': 'Some User',
+                           'email': 'foo example com'})
+        openid_response.addExtension(sreg_response)
+        response = self.complete(openid_response)
+        self.assertRedirects(response, 'http://testserver/getuser/')
+
+        # And they are now logged in as a new user (they haven't taken
+        # over the existing "someuser" user).
+        response = self.client.get('/getuser/')
+        self.assertEquals(response.content, 'someuser2')
+
+        # Check the details of the new user.
+        user = User.objects.get(username='someuser2')
+        self.assertEquals(user.first_name, 'Some')
+        self.assertEquals(user.last_name, 'User')
+        self.assertEquals(user.email, 'foo example com')
+
+    def test_login_update_details(self):
+        settings.OPENID_UPDATE_DETAILS_FROM_SREG = True
+        user = User.objects.create_user('testuser', 'someone example com')
+        useropenid = UserOpenID(
+            user=user,
+            claimed_id='http://example.com/identity',
+            display_id='http://example.com/identity')
+        useropenid.save()
+
+        # Posting in an identity URL begins the authentication request:
+        response = self.client.post('/openid/login/',
+            {'openid_identifier': 'http://example.com/identity',
+             'next': '/getuser/'})
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        # Complete the request, passing back some simple registration
+        # data.  The user is redirected to the next URL.
+        openid_request = self.provider.parseFormPost(response.content)
+        sreg_request = SRegRequest.fromOpenIDRequest(openid_request)
+        openid_response = openid_request.answer(True)
+        sreg_response = SRegResponse.extractResponse(
+            sreg_request, {'nickname': 'someuser', 'fullname': 'Some User',
+                           'email': 'foo example com'})
+        openid_response.addExtension(sreg_response)
+        response = self.complete(openid_response)
+        self.assertRedirects(response, 'http://testserver/getuser/')
+
+        # And they are now logged in as testuser (the passed in
+        # nickname has not caused the username to change).
+        response = self.client.get('/getuser/')
+        self.assertEquals(response.content, 'testuser')
+
+        # The user's full name and email have been updated.
+        user = User.objects.get(username='testuser')
+        self.assertEquals(user.first_name, 'Some')
+        self.assertEquals(user.last_name, 'User')
+        self.assertEquals(user.email, 'foo example com')
+
+    def test_login_teams(self):
+        settings.OPENID_LAUNCHPAD_TEAMS_MAPPING = {'teamname': 'groupname',
+                                                   'otherteam': 'othergroup'}
+        user = User.objects.create_user('testuser', 'someone example com')
+        group = Group(name='groupname')
+        group.save()
+        ogroup = Group(name='othergroup')
+        ogroup.save()
+        user.groups.add(ogroup)
+        user.save()
+        useropenid = UserOpenID(
+            user=user,
+            claimed_id='http://example.com/identity',
+            display_id='http://example.com/identity')
+        useropenid.save()
+
+        # Posting in an identity URL begins the authentication request:
+        response = self.client.post('/openid/login/',
+            {'openid_identifier': 'http://example.com/identity',
+             'next': '/getuser/'})
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        # Complete the request
+        openid_request = self.provider.parseFormPost(response.content)
+        openid_response = openid_request.answer(True)
+        teams_request = teams.TeamsRequest.fromOpenIDRequest(openid_request)
+        teams_response = teams.TeamsResponse.extractResponse(
+            teams_request, 'teamname,some-other-team')
+        openid_response.addExtension(teams_response)
+        response = self.complete(openid_response)
+        self.assertRedirects(response, 'http://testserver/getuser/')
+
+        # And they are now logged in as testuser
+        response = self.client.get('/getuser/')
+        self.assertEquals(response.content, 'testuser')
+
+        # The user's groups have been updated.
+        user = User.objects.get(username='testuser')
+        self.assertTrue(group in user.groups.all())
+        self.assertTrue(ogroup not in user.groups.all())
+
+    def test_login_teams_automapping(self):
+        settings.OPENID_LAUNCHPAD_TEAMS_MAPPING = {'teamname': 'groupname',
+                                                   'otherteam': 'othergroup'}
+        settings.OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO = True
+        settings.OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO_BLACKLIST = ['django-group1', 'django-group2']
+        user = User.objects.create_user('testuser', 'someone example com')
+        group1 = Group(name='django-group1')
+        group1.save()
+        group2 = Group(name='django-group2')
+        group2.save()
+        group3 = Group(name='django-group3')
+        group3.save()
+        user.save()
+        useropenid = UserOpenID(
+            user=user,
+            claimed_id='http://example.com/identity',
+            display_id='http://example.com/identity')
+        useropenid.save()
+
+        # Posting in an identity URL begins the authentication request:
+        response = self.client.post('/openid/login/',
+            {'openid_identifier': 'http://example.com/identity',
+             'next': '/getuser/'})
+        self.assertContains(response, 'OpenID transaction in progress')
+
+        # Complete the request
+        openid_request = self.provider.parseFormPost(response.content)
+        openid_response = openid_request.answer(True)
+        teams_request = teams.TeamsRequest.fromOpenIDRequest(openid_request)
+        
+        self.assertEqual(group1 in user.groups.all(), False)
+        self.assertEqual(group2 in user.groups.all(), False)
+        self.assertTrue(group3 not in user.groups.all())
+
+class HelperFunctionsTest(TestCase):
+    def test_sanitise_redirect_url(self):
+        settings.ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS = [
+            "example.com", "example.org"]
+        # list of URLs and whether they should be passed or not
+        urls = [
+            ("http://example.com";, True),
+            ("http://example.org/";, True),
+            ("http://example.org/foo/bar";, True),
+            ("http://example.org/foo/bar?baz=quux";, True),
+            ("http://example.org:9999/foo/bar?baz=quux";, True),
+            ("http://www.example.org/";, False),
+            ("http://example.net/foo/bar?baz=quux";, False),
+            ("/somewhere/local", True),
+            ("/somewhere/local?url=http://fail.com/bar";, True),
+            # An empty path, as seen when no "next" parameter is passed.
+            ("", False),
+            ("/path with spaces", False),
+        ]
+        for url, returns_self in urls:
+            sanitised = sanitise_redirect_url(url)
+            if returns_self:
+                self.assertEqual(url, sanitised)
+            else:
+                self.assertEqual(settings.LOGIN_REDIRECT_URL, sanitised)
+        
+def suite():
+    return unittest.TestLoader().loadTestsFromName(__name__)
diff --git a/lib/django_openid_auth/tests/urls.py b/lib/django_openid_auth/tests/urls.py
new file mode 100644
index 0000000..f241550
--- /dev/null
+++ b/lib/django_openid_auth/tests/urls.py
@@ -0,0 +1,39 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from django.http import HttpResponse
+from django.conf.urls.defaults import *
+
+
+def get_user(request):
+    return HttpResponse(request.user.username)
+
+urlpatterns = patterns('',
+    (r'^getuser/$', get_user),
+    (r'^openid/', include('django_openid_auth.urls')),
+)
diff --git a/lib/django_openid_auth/urls.py b/lib/django_openid_auth/urls.py
new file mode 100644
index 0000000..6df0561
--- /dev/null
+++ b/lib/django_openid_auth/urls.py
@@ -0,0 +1,36 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2007 Simon Willison
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+from django.conf.urls.defaults import *
+
+urlpatterns = patterns('django_openid_auth.views',
+    (r'^login/$', 'login_begin'),
+    (r'^complete/$', 'login_complete'),
+    url(r'^logo.gif$', 'logo', name='openid-logo'),
+)
diff --git a/lib/django_openid_auth/views.py b/lib/django_openid_auth/views.py
new file mode 100644
index 0000000..5488cf2
--- /dev/null
+++ b/lib/django_openid_auth/views.py
@@ -0,0 +1,239 @@
+# django-openid-auth -  OpenID integration for django.contrib.auth
+#
+# Copyright (C) 2007 Simon Willison
+# Copyright (C) 2008-2009 Canonical Ltd.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions
+# are met:
+#
+# * Redistributions of source code must retain the above copyright
+# notice, this list of conditions and the following disclaimer.
+#
+# * Redistributions in binary form must reproduce the above copyright
+# notice, this list of conditions and the following disclaimer in the
+# documentation and/or other materials provided with the distribution.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+# "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+# LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
+# FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
+# COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
+# INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+# BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+# CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+# LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
+# ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import re
+import urllib
+from urlparse import urlsplit
+
+from django.conf import settings
+from django.contrib.auth import (
+    REDIRECT_FIELD_NAME, authenticate, login as auth_login)
+from django.contrib.auth.models import Group
+from django.core.urlresolvers import reverse
+from django.http import HttpResponse, HttpResponseRedirect
+from django.shortcuts import render_to_response
+from django.template import RequestContext
+from django.template.loader import render_to_string
+
+from openid.consumer.consumer import (
+    Consumer, SUCCESS, CANCEL, FAILURE)
+from openid.consumer.discover import DiscoveryFailure
+from openid.extensions import sreg
+
+from django_openid_auth import teams
+from django_openid_auth.forms import OpenIDLoginForm
+from django_openid_auth.store import DjangoOpenIDStore
+
+
+next_url_re = re.compile('^/[-\w/]+$')
+
+def is_valid_next_url(next):
+    # When we allow this:
+    #   /openid/?next=/welcome/
+    # For security reasons we want to restrict the next= bit to being a local 
+    # path, not a complete URL.
+    return bool(next_url_re.match(next))
+
+
+def sanitise_redirect_url(redirect_to):
+    """Sanitise the redirection URL."""
+    # Light security check -- make sure redirect_to isn't garbage.
+    is_valid = True
+    if not redirect_to or ' ' in redirect_to:
+        is_valid = False
+    elif '//' in redirect_to:
+        # Allow the redirect URL to be external if it's a permitted domain
+        allowed_domains = getattr(settings, 
+            "ALLOWED_EXTERNAL_OPENID_REDIRECT_DOMAINS", [])
+        s, netloc, p, q, f = urlsplit(redirect_to)
+        # allow it if netloc is blank or if the domain is allowed
+        if netloc:
+            # a domain was specified. Is it an allowed domain?
+            if netloc.find(":") != -1:
+                netloc, _ = netloc.split(":", 1)
+            if netloc not in allowed_domains:
+                is_valid = False
+
+    # If the return_to URL is not valid, use the default.
+    if not is_valid:
+        redirect_to = settings.LOGIN_REDIRECT_URL
+
+    return redirect_to
+
+
+def make_consumer(request):
+    """Create an OpenID Consumer object for the given Django request."""
+    # Give the OpenID library its own space in the session object.
+    session = request.session.setdefault('OPENID', {})
+    store = DjangoOpenIDStore()
+    return Consumer(session, store)
+
+
+def render_openid_request(request, openid_request, return_to, trust_root=None):
+    """Render an OpenID authentication request."""
+    if trust_root is None:
+        trust_root = getattr(settings, 'OPENID_TRUST_ROOT',
+                             request.build_absolute_uri('/'))
+
+    if openid_request.shouldSendRedirect():
+        redirect_url = openid_request.redirectURL(
+            trust_root, return_to)
+        return HttpResponseRedirect(redirect_url)
+    else:
+        form_html = openid_request.htmlMarkup(
+            trust_root, return_to, form_tag_attrs={'id': 'openid_message'})
+        return HttpResponse(form_html, content_type='text/html;charset=UTF-8')
+
+
+def render_failure(request, message, status=403):
+    """Render an error page to the user."""
+    data = render_to_string(
+        'openid/failure.html', dict(message=message),
+        context_instance=RequestContext(request))
+    return HttpResponse(data, status=status)
+
+
+def parse_openid_response(request):
+    """Parse an OpenID response from a Django request."""
+    # Short cut if there is no request parameters.
+    #if len(request.REQUEST) == 0:
+    #    return None
+
+    current_url = request.build_absolute_uri()
+
+    consumer = make_consumer(request)
+    return consumer.complete(dict(request.REQUEST.items()), current_url)
+
+
+def login_begin(request, template_name='openid/login.html',
+                redirect_field_name=REDIRECT_FIELD_NAME):
+    """Begin an OpenID login request, possibly asking for an identity URL."""
+    redirect_to = request.REQUEST.get(redirect_field_name, '')
+
+    # Get the OpenID URL to try.  First see if we've been configured
+    # to use a fixed server URL.
+    openid_url = getattr(settings, 'OPENID_SSO_SERVER_URL', None)
+
+    if openid_url is None:
+        if request.POST:
+            login_form = OpenIDLoginForm(data=request.POST)
+            if login_form.is_valid():
+                openid_url = login_form.cleaned_data['openid_identifier']
+        else:
+            login_form = OpenIDLoginForm()
+
+        # Invalid or no form data:
+        if openid_url is None:
+            return render_to_response(template_name, {
+                    'form': login_form,
+                    redirect_field_name: redirect_to
+                    }, context_instance=RequestContext(request))
+
+    error = None
+    consumer = make_consumer(request)
+    try:
+        openid_request = consumer.begin(openid_url)
+    except DiscoveryFailure, exc:
+        return render_failure(
+            request, "OpenID discovery error: %s" % (str(exc),), status=500)
+
+    # Request some user details.
+    openid_request.addExtension(
+        sreg.SRegRequest(optional=['email', 'fullname', 'nickname']))
+
+    # Request team info
+    teams_mapping_auto = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO', False)
+    teams_mapping_auto_blacklist = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING_AUTO_BLACKLIST', [])
+    launchpad_teams = getattr(settings, 'OPENID_LAUNCHPAD_TEAMS_MAPPING', {})
+    if teams_mapping_auto:
+        #ignore launchpad teams. use all django-groups
+        launchpad_teams = dict()
+        all_groups = Group.objects.exclude(name__in=teams_mapping_auto_blacklist)
+        for group in all_groups:
+            launchpad_teams[group.name] = group.name
+
+    if launchpad_teams:
+        openid_request.addExtension(teams.TeamsRequest(launchpad_teams.keys()))
+
+    # Construct the request completion URL, including the page we
+    # should redirect to.
+    return_to = request.build_absolute_uri(reverse(login_complete))
+    if redirect_to:
+        if '?' in return_to:
+            return_to += '&'
+        else:
+            return_to += '?'
+        return_to += urllib.urlencode({redirect_field_name: redirect_to})
+
+    return render_openid_request(request, openid_request, return_to)
+
+
+def login_complete(request, redirect_field_name=REDIRECT_FIELD_NAME):
+    redirect_to = request.REQUEST.get(redirect_field_name, '')
+
+    openid_response = parse_openid_response(request)
+    if not openid_response:
+        return render_failure(
+            request, 'This is an OpenID relying party endpoint.')
+
+    if openid_response.status == SUCCESS:
+        user = authenticate(openid_response=openid_response)
+        if user is not None:
+            if user.is_active:
+                auth_login(request, user)
+                return HttpResponseRedirect(sanitise_redirect_url(redirect_to))
+            else:
+                return render_failure(request, 'Disabled account')
+        else:
+            return render_failure(request, 'Unknown user')
+    elif openid_response.status == FAILURE:
+        return render_failure(
+            request, 'OpenID authentication failed: %s' %
+            openid_response.message)
+    elif openid_response.status == CANCEL:
+        return render_failure(request, 'Authentication cancelled')
+    else:
+        assert False, (
+            "Unknown OpenID response type: %r" % openid_response.status)
+
+
+def logo(request):
+    return HttpResponse(
+        OPENID_LOGO_BASE_64.decode('base64'), mimetype='image/gif'
+    )
+
+# Logo from http://openid.net/login-bg.gif
+# Embedded here for convenience; you should serve this as a static file
+OPENID_LOGO_BASE_64 = """
+R0lGODlhEAAQAMQAAO3t7eHh4srKyvz8/P5pDP9rENLS0v/28P/17tXV1dHEvPDw8M3Nzfn5+d3d
+3f5jA97Syvnv6MfLzcfHx/1mCPx4Kc/S1Pf189C+tP+xgv/k1N3OxfHy9NLV1/39/f///yH5BAAA
+AAAALAAAAAAQABAAAAVq4CeOZGme6KhlSDoexdO6H0IUR+otwUYRkMDCUwIYJhLFTyGZJACAwQcg
+EAQ4kVuEE2AIGAOPQQAQwXCfS8KQGAwMjIYIUSi03B7iJ+AcnmclHg4TAh0QDzIpCw4WGBUZeikD
+Fzk0lpcjIQA7
+"""
diff --git a/settings.py b/settings.py
index 5ab267b..e3cfc84 100644
--- a/settings.py
+++ b/settings.py
@@ -115,6 +115,7 @@ INSTALLED_APPS = (
     'core',
     'api',
     'accounts',
+    'django_openid_auth',
     'notes',
 
     # System apps
@@ -143,7 +144,11 @@ ACCOUNT_ACTIVATION_DAYS = 15
 
 AUTH_PROFILE_MODULE = 'accounts.UserProfile'
 
+OPENID_CREATE_USERS = True
+OPENID_UPDATE_DETAILS_FROM_SREG = True
+
 LOGIN_REDIRECT_URL = '/'
+LOGIN_URL = '/openid/login/'
 
 # local_settings.py can be used to override environment-specific settings
 # like database and email that differ between development and production.
diff --git a/urls.py b/urls.py
index 46fbae4..a995afc 100644
--- a/urls.py
+++ b/urls.py
@@ -28,7 +28,7 @@ urlpatterns = patterns('',
 
     (r'^api/', include('snowy.api.urls')),
     (r'^accounts/', include('snowy.accounts.urls')),
-
+    (r'^openid/', include('django_openid_auth.urls')),
     (r'^admin/doc/', include('django.contrib.admindocs.urls')),
     (r'^admin/', include(admin.site.urls)),
 



[Date Prev][Date Next]   [Thread Prev][Thread Next]   [Thread Index] [Date Index] [Author Index]