]> git.parisson.com Git - django-social-auth.git/commitdiff
Initial commit for django-social-auth
authorMatías Aguirre <matiasaguirre@gmail.com>
Mon, 8 Nov 2010 10:07:59 +0000 (08:07 -0200)
committerMatías Aguirre <matiasaguirre@gmail.com>
Mon, 8 Nov 2010 10:07:59 +0000 (08:07 -0200)
23 files changed:
.gitignore [new file with mode: 0644]
COPYRIGHT [new file with mode: 0644]
LICENCE [new file with mode: 0644]
LICENCE.django-openid-auth [new file with mode: 0644]
README.rst [new file with mode: 0644]
example/__init__.py [new file with mode: 0644]
example/app/__init__.py [new file with mode: 0644]
example/app/views.py [new file with mode: 0644]
example/local_settings.py.template [new file with mode: 0644]
example/manage.py [new file with mode: 0755]
example/settings.py [new file with mode: 0644]
example/social_auth [new symlink]
example/urls.py [new file with mode: 0644]
social_auth/__init__.py [new file with mode: 0644]
social_auth/admin.py [new file with mode: 0644]
social_auth/backends.py [new file with mode: 0644]
social_auth/base.py [new file with mode: 0644]
social_auth/conf.py [new file with mode: 0644]
social_auth/models.py [new file with mode: 0644]
social_auth/oauth.py [new file with mode: 0644]
social_auth/store.py [new file with mode: 0644]
social_auth/urls.py [new file with mode: 0644]
social_auth/views.py [new file with mode: 0644]

diff --git a/.gitignore b/.gitignore
new file mode 100644 (file)
index 0000000..5e2a46f
--- /dev/null
@@ -0,0 +1,3 @@
+*.pyc
+.*.sw[po]
+local_settings.py
diff --git a/COPYRIGHT b/COPYRIGHT
new file mode 100644 (file)
index 0000000..005d385
--- /dev/null
+++ b/COPYRIGHT
@@ -0,0 +1,2 @@
+Original Copyright goes to Henrik Lied (henriklied)
+Code borrowed from https://github.com/henriklied/django-twitter-oauth
diff --git a/LICENCE b/LICENCE
new file mode 100644 (file)
index 0000000..54de2e1
--- /dev/null
+++ b/LICENCE
@@ -0,0 +1,27 @@
+Copyright (c) 2010, Matías Aguirre
+All rights reserved.
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+    1. Redistributions of source code must retain the above copyright notice,
+       this list of conditions and the following disclaimer.
+    
+    2. 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.
+    3. Neither the name of this project nor the names of its contributors may be
+       used to endorse or promote products derived from this software without
+       specific prior written permission.
+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/LICENCE.django-openid-auth b/LICENCE.django-openid-auth
new file mode 100644 (file)
index 0000000..abbb525
--- /dev/null
@@ -0,0 +1,27 @@
+django-openid-auth -  OpenID integration for django.contrib.auth
+Copyright (C) 2007 Simon Willison
+Copyright (C) 2008-2010 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/README.rst b/README.rst
new file mode 100644 (file)
index 0000000..289c419
--- /dev/null
@@ -0,0 +1,6 @@
+Django Social Auth
+==================
+
+1. Description
+--------------
+Basically this is a take
diff --git a/example/__init__.py b/example/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/example/app/__init__.py b/example/app/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/example/app/views.py b/example/app/views.py
new file mode 100644 (file)
index 0000000..535be2a
--- /dev/null
@@ -0,0 +1,71 @@
+from django.http import HttpResponseRedirect, HttpResponse
+from django.contrib.auth import logout as auth_logout
+from django.contrib.auth.decorators import login_required
+from django.template import Template, Context, RequestContext
+
+
+def home(request):
+    return HttpResponse(Template(
+    """
+    <html>
+      <head>
+        <title>Social access</title>
+      </head>
+      <body>
+        <h1>Login using any of the following methods:</h1>
+        <div style="padding-left: 30px;">
+          <div>
+            <h2>Login using <a href="http://oauth.net/">OAuth</a> from:</h2>
+            <ul>
+              <li><a href="/login/twitter/">Twitter</a></li>
+              <li><a href="/login/facebook/">Facebook</a></li>
+            </ul>
+          </div>
+          <div>
+            <h2>Login using <a href="http://openid.net/">OpenId</a> from:</h2>
+            <ul>
+              <li><a href="/login/google/">Google</a></li>
+              <li><a href="/login/yahoo/">Yahoo</a></li>
+              <li>
+                <form action="/login/openid/" method="post">{% csrf_token %}
+                  <label for="openid_identifier">Other provider:</label>
+                  <input id="openid_identifier" type="text" value="" name="openid_identifier" />
+                  <input type="submit" />
+                </form>
+              </li>
+            </ul>
+          </div>
+        </div>
+      </body>
+    </html>
+    """).render(Context(RequestContext(request))),
+    content_type='text/html;charset=UTF-8')
+
+@login_required
+def done(request):
+    user = request.user
+    return HttpResponse(Template(
+    """
+    <html>
+      <head>
+        <title>Logged in</title>
+        <style>th{text-align: left;}</style>
+      </head>
+      <body>
+        <h1>Logged in!</h1>
+        <table>
+          <tr><th>Id:</th> <td>{{ user.id }}</td></tr>
+          <tr><th>Username:</th> <td>{{ user.username }}</td></tr>
+          <tr><th>Email:</th> <td>{{ user.email|default:"Not provided" }}</td></tr>
+          <tr><th>First name:</th> <td>{{ user.first_name|default:"Not provided" }}</td></tr>
+          <tr><th>Last name:</th> <td>{{ user.last_name|default:"Not provided" }}</td></tr>
+        </table>
+        <p><a href="/logout/">Logout</a></p>
+      </body>
+    </html>
+    """).render(Context({'user':user})),
+    content_type='text/html;charset=UTF-8')
+
+def logout(request):
+    auth_logout(request)
+    return HttpResponseRedirect('/')
diff --git a/example/local_settings.py.template b/example/local_settings.py.template
new file mode 100644 (file)
index 0000000..31339d5
--- /dev/null
@@ -0,0 +1,7 @@
+TWITTER_CONSUMER_KEY              = ''
+TWITTER_CONSUMER_SECRET           = ''
+FACEBOOK_APP_ID                   = ''
+FACEBOOK_API_SECRET               = ''
+SOCIAL_AUTH_CREATE_USERS          = True
+SOCIAL_AUTH_FORCE_RANDOM_USERNAME = False
+SOCIAL_AUTH_DEFAULT_USERNAME      = 'socialauth_user'
diff --git a/example/manage.py b/example/manage.py
new file mode 100755 (executable)
index 0000000..5e78ea9
--- /dev/null
@@ -0,0 +1,11 @@
+#!/usr/bin/env python
+from django.core.management import execute_manager
+try:
+    import settings # Assumed to be in the same directory.
+except ImportError:
+    import sys
+    sys.stderr.write("Error: Can't find the file 'settings.py' in the directory containing %r. It appears you've customized things.\nYou'll have to run django-admin.py, passing it your settings module.\n(If the file settings.py does indeed exist, it's causing an ImportError somehow.)\n" % __file__)
+    sys.exit(1)
+
+if __name__ == "__main__":
+    execute_manager(settings)
diff --git a/example/settings.py b/example/settings.py
new file mode 100644 (file)
index 0000000..68f62f1
--- /dev/null
@@ -0,0 +1,105 @@
+DEBUG = True
+TEMPLATE_DEBUG = DEBUG
+
+ADMINS = (
+    # ('Your Name', 'your_email@domain.com'),
+)
+
+MANAGERS = ADMINS
+
+DATABASES = {
+    'default': {
+        'ENGINE': 'django.db.backends.sqlite3', # Add 'postgresql_psycopg2', 'postgresql', 'mysql', 'sqlite3' or 'oracle'.
+        'NAME': 'test.db',                      # Or path to database file if using sqlite3.
+        'USER': '',                      # Not used with sqlite3.
+        'PASSWORD': '',                  # Not used with sqlite3.
+        'HOST': '',                      # Set to empty string for localhost. Not used with sqlite3.
+        'PORT': '',                      # Set to empty string for default. Not used with sqlite3.
+    }
+}
+
+# Local time zone for this installation. Choices can be found here:
+# http://en.wikipedia.org/wiki/List_of_tz_zones_by_name
+# although not all choices may be available on all operating systems.
+# On Unix systems, a value of None will cause Django to use the same
+# timezone as the operating system.
+# If running in a Windows environment this must be set to the same as your
+# system time zone.
+TIME_ZONE = 'America/Chicago'
+
+# Language code for this installation. All choices can be found here:
+# http://www.i18nguy.com/unicode/language-identifiers.html
+LANGUAGE_CODE = 'en-us'
+
+SITE_ID = 1
+
+# If you set this to False, Django will make some optimizations so as not
+# to load the internationalization machinery.
+USE_I18N = True
+
+# If you set this to False, Django will not format dates, numbers and
+# calendars according to the current locale
+USE_L10N = True
+
+# Absolute path to the directory that holds media.
+# Example: "/home/media/media.lawrence.com/"
+MEDIA_ROOT = ''
+
+# URL that handles the media served from MEDIA_ROOT. Make sure to use a
+# trailing slash if there is a path component (optional in other cases).
+# Examples: "http://media.lawrence.com", "http://example.com/media/"
+MEDIA_URL = ''
+
+# URL prefix for admin media -- CSS, JavaScript and images. Make sure to use a
+# trailing slash.
+# Examples: "http://foo.com/media/", "/media/".
+ADMIN_MEDIA_PREFIX = '/media/'
+
+# Make this unique, and don't share it with anybody.
+SECRET_KEY = 't2eo^kd%k+-##ml3@_x__$j0(ps4p0q6eg*c4ttp9d2n(t!iol'
+
+# List of callables that know how to import templates from various sources.
+TEMPLATE_LOADERS = (
+    'django.template.loaders.filesystem.Loader',
+    'django.template.loaders.app_directories.Loader',
+#     'django.template.loaders.eggs.Loader',
+)
+
+MIDDLEWARE_CLASSES = (
+    'django.middleware.common.CommonMiddleware',
+    'django.contrib.sessions.middleware.SessionMiddleware',
+    'django.middleware.csrf.CsrfViewMiddleware',
+    'django.contrib.auth.middleware.AuthenticationMiddleware',
+    'django.contrib.messages.middleware.MessageMiddleware',
+)
+
+ROOT_URLCONF = 'example.urls'
+
+TEMPLATE_DIRS = (
+    # Put strings here, like "/home/html/django_templates" or "C:/www/django/templates".
+    # Always use forward slashes, even on Windows.
+    # Don't forget to use absolute paths, not relative paths.
+)
+
+INSTALLED_APPS = (
+    'django.contrib.auth',
+    'django.contrib.contenttypes',
+    'django.contrib.sessions',
+    'django.contrib.sites',
+    'django.contrib.messages',
+    # Uncomment the next line to enable the admin:
+    # 'django.contrib.admin',
+    'social_auth',
+)
+
+AUTHENTICATION_BACKENDS = (
+    'social_auth.backends.TwitterOAuthBackend',
+    'social_auth.backends.FacebookOAuthBackend',
+    'social_auth.backends.OpenIDBackend',
+    'django.contrib.auth.backends.ModelBackend',
+)
+
+try:
+    from local_settings import *
+except:
+    pass
diff --git a/example/social_auth b/example/social_auth
new file mode 120000 (symlink)
index 0000000..fdc701c
--- /dev/null
@@ -0,0 +1 @@
+../social_auth/
\ No newline at end of file
diff --git a/example/urls.py b/example/urls.py
new file mode 100644 (file)
index 0000000..610e798
--- /dev/null
@@ -0,0 +1,12 @@
+from django.conf.urls.defaults import *
+
+# Uncomment the next two lines to enable the admin:
+# from django.contrib import admin
+# admin.autodiscover()
+
+urlpatterns = patterns('',
+    (r'^', include('social_auth.urls', namespace='social')),
+
+    # Uncomment the next line to enable the admin:
+    # (r'^admin/', include(admin.site.urls)),
+)
diff --git a/social_auth/__init__.py b/social_auth/__init__.py
new file mode 100644 (file)
index 0000000..e69de29
diff --git a/social_auth/admin.py b/social_auth/admin.py
new file mode 100644 (file)
index 0000000..d2d5d51
--- /dev/null
@@ -0,0 +1,27 @@
+from django.contrib import admin
+
+from .models import UserSocialAuth, Nonce, Association
+
+
+class UserSocialAuthOption(admin.ModelAdmin):
+    """Social Auth user options"""
+    list_display = ('id', 'user', 'provider')
+    search_fields = ('user__name',)
+    list_filter = ('provider',)
+    raw_id_fields = ('user',)
+
+
+class NonceOption(admin.ModelAdmin):
+    """Nonce options"""
+    list_display = ('id', 'server_url', 'timestamp', 'salt')
+
+
+class AssociationOption(admin.ModelAdmin):
+    """Association options"""
+    list_display = ('id', 'server_url', 'assoc_type')
+    list_filter = ('assoc_type',)
+
+
+admin.site.register(UserSocialAuth, UserSocialAuthOption)
+admin.site.register(Nonce, NonceOption)
+admin.site.register(Association, AssociationOption)
diff --git a/social_auth/backends.py b/social_auth/backends.py
new file mode 100644 (file)
index 0000000..9511a05
--- /dev/null
@@ -0,0 +1,96 @@
+from openid.extensions import ax, sreg
+
+from .base import SocialAuthBackend
+from .openid_auth import OLD_AX_ATTRS, AX_SCHEMA_ATTRS
+
+
+class OAuthBackend(SocialAuthBackend):
+    """OAuth authentication backend base class"""
+    name = 'oauth'
+
+    def get_user_id(self, details, response):
+        "OAuth providers return an unique user id in response"""
+        return response['id']
+
+
+class TwitterOAuthBackend(OAuthBackend):
+    """Twitter OAuth authentication backend"""
+    name = 'twitter'
+
+    def authenticate(self, **kwargs):
+        if kwargs.pop('twitter', False):
+            return super(TwitterOAuthBackend, self).authenticate(**kwargs)
+
+    def get_user_details(self, response):
+        return {'email': '', # not supplied
+                'username': response['screen_name'],
+                'fullname': response['name'],
+                'firstname': response['name'],
+                'lastname': ''}
+
+
+class FacebookOAuthBackend(OAuthBackend):
+    """Facebook OAuth authentication backend"""
+    name = 'facebook'
+
+    def authenticate(self, **kwargs):
+        if kwargs.pop('facebook', False):
+            return super(FacebookOAuthBackend, self).authenticate(**kwargs)
+
+    def get_user_details(self, response):
+        return {'email': response.get('email', ''),
+                'username': response['name'],
+                'fullname': response['name'],
+                'firstname': response.get('first_name', ''),
+                'lastname': response.get('last_name', '')}
+
+       
+class OpenIDBackend(SocialAuthBackend):
+    """Generic OpenID authentication backend"""
+    name = 'openid'
+
+    def authenticate(self, **kwargs):
+        """Authenticate the user based on an OpenID response."""
+        if kwargs.pop('openid', False):
+            return super(OpenIDBackend, self).authenticate(**kwargs)
+
+    def get_user_id(self, details, response):
+        return response.identity_url
+
+    def get_user_details(self, response):
+        values = {'email': None,
+                  'username': None,
+                  'fullname': None,
+                  'firstname': None,
+                  'lastname': None}
+
+        resp = sreg.SRegResponse.fromSuccessResponse(response)
+        if resp:
+            values.update({'email': resp.get('email'),
+                           'fullname': resp.get('fullname'),
+                           'username': resp.get('nickname')})
+
+        # Use Attribute Exchange attributes if provided
+        resp = ax.FetchResponse.fromSuccessResponse(response)
+        if resp:
+            values.update((alias.replace('old_', ''), resp.getSingle(src))
+                            for src, alias in OLD_AX_ATTRS + AX_SCHEMA_ATTRS)
+
+        fullname = values.get('fullname', '')
+        firstname = values.get('firstname', '')
+        lastname = values.get('lastname', '')
+
+        if not fullname and firstname and lastname:
+            fullname = firstname + ' ' + lastname
+        elif fullname:
+            try: # Try to split name for django user storage
+                firstname, lastname = fullname.rsplit(' ', 1)
+            except ValueError:
+                lastname = fullname
+
+        values.update({'fullname': fullname,
+                       'firstname': firstname,
+                       'lastname': lastname,
+                       'username': values.get('username') or \
+                                   (firstname.title() + lastname.title())})
+        return values
diff --git a/social_auth/base.py b/social_auth/base.py
new file mode 100644 (file)
index 0000000..03628ec
--- /dev/null
@@ -0,0 +1,133 @@
+import os
+import md5
+
+from django.conf import settings
+from django.contrib.auth.models import User
+from django.contrib.auth.backends import ModelBackend
+
+from .models import UserSocialAuth
+
+
+class BaseAuth(object):
+    """Base authentication class, new authenticators should subclass
+    and implement needed methods"""
+    def __init__(self, request, redirect):
+        self.request = request
+        self.redirect = redirect
+
+    def auth_url(self):
+        """Must return redirect URL to auth provider"""
+        raise NotImplementedError, 'Implement in subclass'
+    
+    def auth_html(self):
+        """Must return login HTML content returned by provider"""
+        raise NotImplementedError, 'Implement in subclass'
+    
+    def auth_complete(self):
+        """Completes loging process, must return user instance"""
+        raise NotImplementedError, 'Implement in subclass'
+
+    @property
+    def uses_redirect(self):
+        """Return True if this provider uses redirect url method,
+        otherwise return false."""
+        return True
+
+
+class SocialAuthBackend(ModelBackend):
+    """A django.contrib.auth backend that authenticates the user based on
+    a authentication provider response"""
+    name = '' # provider name, it's stored in database
+
+    def authenticate(self, **kwargs):
+        """Authenticate the user based on an OAuth response."""
+        # Require that the OAuth response be passed in as a keyword
+        # argument, to make sure we don't match the username/password
+        # calling conventions of authenticate.
+        response = kwargs.get('response')
+        if response is None:
+            return None
+
+        details = self.get_user_details(response)
+        uid = self.get_user_id(details, response)
+        try:
+            oauth_user = UserSocialAuth.objects.select_related('user')\
+                                               .get(provider=self.name,
+                                                    uid=uid)
+        except UserSocialAuth.DoesNotExist:
+            if not getattr(settings, 'SOCIAL_AUTH_CREATE_USERS', False):
+                return None
+            user = self.create_user(response, details)
+        else:
+            user = oauth_user.user
+            self.update_user_details(user, details)
+        return user
+    
+    def get_username(self, details):
+        def get_random_username():
+            return md5.md5(str(os.urandom(10))).hexdigest()[:30]
+
+        if getattr(settings, 'SOCIAL_AUTH_FORCE_RANDOM_USERNAME', False):
+            username = get_random_username()
+        else:
+            username = details.get('username') or \
+                       getattr(settings, 'SOCIAL_AUTH_DEFAULT_USERNAME', '') or \
+                       get_random_username()
+
+        name, idx = username, 2
+        while True:
+            try:
+                User.objects.get(username=name)
+                name = username + str(idx)
+                idx += 1
+            except User.DoesNotExist:
+                username = name
+                break
+        return username
+
+    def create_user(self, response, details):
+        username = self.get_username(details)
+        user = User.objects.create_user(username, details.get('email', ''))
+        self.update_user_details(user, details)
+        self.associate_auth(user, response, details)
+        return user
+
+    def associate_auth(self, user, response, details):
+        """Associate an OAuth with a user account."""
+        # Check to see if this OAuth has already been claimed.
+        uid = self.get_user_id(details, response)
+        try:
+            user_oauth = UserSocialAuth.objects.select_related('user')\
+                                               .get(provider=self.name,
+                                                    uid=uid)
+        except UserSocialAuth.DoesNotExist:
+            user_oauth = UserSocialAuth.objects.create(user=user, uid=uid,
+                                                       provider=self.name)
+        else:
+            if user_oauth.user != user:
+                raise ValueError, 'The identity has already been claimed'
+        return user_oauth
+
+    def update_user_details(self, user, details):
+        first_name = details.get('firstname') or user.first_name
+        last_name = details.get('lastname') or user.last_name
+        email = details.get('email') or user.email
+        if (user.first_name, user.last_name, user.email) != (first_name, last_name, email):
+            user.first_name = first_name
+            user.last_name = last_name
+            user.email = email
+            user.save()
+
+    def get_user_id(self, details, response):
+        """Must return a unique ID from values returned on details"""
+        raise NotImplementedError, 'Implement in subclass'
+
+    def get_user_details(self, response):
+        """Must return user details in a know internal struct:
+            {'email': <user email if any>,
+             'username': <username if any>,
+             'fullname': <user full name if any>,
+             'firstname': <user first name if any>,
+             'lastname': <user last name if any>}
+        """
+        raise NotImplementedError, 'Implement in subclass'
diff --git a/social_auth/conf.py b/social_auth/conf.py
new file mode 100644 (file)
index 0000000..ef13cb7
--- /dev/null
@@ -0,0 +1,35 @@
+# Twitter configuration
+TWITTER_SERVER          = 'api.twitter.com'
+REQUEST_TOKEN_URL       = 'https://%s/oauth/request_token' % TWITTER_SERVER
+ACCESS_TOKEN_URL        = 'https://%s/oauth/access_token' % TWITTER_SERVER
+AUTHORIZATION_URL       = 'http://%s/oauth/authorize' % TWITTER_SERVER
+TWITTER_CHECK_AUTH      = 'https://twitter.com/account/verify_credentials.json'
+UNAUTHORIZED_TOKEN_NAME = 'twitter_unauthorized_token'
+
+# Facebook configuration
+FACEBOOK_SERVER         = 'graph.facebook.com'
+AUTHORIZATION_URL       = 'https://%s/oauth/authorize' % FACEBOOK_SERVER
+ACCESS_TOKEN_URL        = 'https://%s/oauth/access_token' % FACEBOOK_SERVER
+FACEBOOK_CHECK_AUTH     = 'https://%s/me' % FACEBOOK_SERVER
+
+# OpenID configuration
+OLD_AX_ATTRS = [
+    ('http://schema.openid.net/contact/email', 'old_email'),
+    ('http://schema.openid.net/namePerson', 'old_fullname'),
+    ('http://schema.openid.net/namePerson/friendly', 'old_nickname')
+]
+AX_SCHEMA_ATTRS = [
+    # Request both the full name and first/last components since some
+    # providers offer one but not the other.
+    ('http://axschema.org/contact/email', 'email'),
+    ('http://axschema.org/namePerson', 'fullname'),
+    ('http://axschema.org/namePerson/first', 'firstname'),
+    ('http://axschema.org/namePerson/last', 'lastname'),
+    ('http://axschema.org/namePerson/friendly', 'nickname'),
+]
+AX_ATTRS          = AX_SCHEMA_ATTRS + OLD_AX_ATTRS
+SREG_ATTR         = ['email', 'fullname', 'nickname']
+OPENID_ID_FIELD   = 'openid_identifier'
+SESSION_NAME      = 'openid'
+OPENID_GOOGLE_URL = 'https://www.google.com/accounts/o8/id'
+OPENID_YAHOO_URL  = 'http://yahoo.com'
diff --git a/social_auth/models.py b/social_auth/models.py
new file mode 100644 (file)
index 0000000..21baecb
--- /dev/null
@@ -0,0 +1,26 @@
+from django.db import models 
+from django.contrib.auth.models import User
+
+
+class UserSocialAuth(models.Model):
+    user = models.ForeignKey(User)
+    provider = models.CharField(max_length=32)
+    uid = models.CharField(max_length=2048)
+
+    class Meta:
+        unique_together = ('provider', 'uid')
+
+
+class Nonce(models.Model):
+    server_url = models.CharField(max_length=2047)
+    timestamp = models.IntegerField()
+    salt = models.CharField(max_length=40)
+
+
+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)
diff --git a/social_auth/oauth.py b/social_auth/oauth.py
new file mode 100644 (file)
index 0000000..fd1f6a5
--- /dev/null
@@ -0,0 +1,524 @@
+import cgi
+import urllib
+import time
+import random
+import urlparse
+import hmac
+import binascii
+
+VERSION = '1.0' # Hi Blaine!
+HTTP_METHOD = 'GET'
+SIGNATURE_METHOD = 'PLAINTEXT'
+
+# Generic exception class
+class OAuthError(RuntimeError):
+    def __init__(self, message='OAuth error occured.'):
+        self.message = message
+
+# optional WWW-Authenticate header (401 error)
+def build_authenticate_header(realm=''):
+    return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
+
+# url escape
+def escape(s):
+    # escape '/' too
+    return urllib.quote(s, safe='~')
+
+# util function: current timestamp
+# seconds since epoch (UTC)
+def generate_timestamp():
+    return int(time.time())
+
+# util function: nonce
+# pseudorandom number
+def generate_nonce(length=8):
+    return ''.join([str(random.randint(0, 9)) for i in range(length)])
+
+# OAuthConsumer is a data type that represents the identity of the Consumer
+# via its shared secret with the Service Provider.
+class OAuthConsumer(object):
+    key = None
+    secret = None
+
+    def __init__(self, key, secret):
+        self.key = key
+        self.secret = secret
+
+# OAuthToken is a data type that represents an End User via either an access
+# or request token.     
+class OAuthToken(object):
+    # access tokens and request tokens
+    key = None
+    secret = None
+
+    '''
+    key = the token
+    secret = the token secret
+    '''
+    def __init__(self, key, secret):
+        self.key = key
+        self.secret = secret
+
+    def to_string(self):
+        return urllib.urlencode({'oauth_token': self.key, 'oauth_token_secret': self.secret})
+
+    # return a token from something like:
+    # oauth_token_secret=digg&oauth_token=digg
+    def from_string(s):
+        params = cgi.parse_qs(s, keep_blank_values=False)
+        key = params['oauth_token'][0]
+        secret = params['oauth_token_secret'][0]
+        return OAuthToken(key, secret)
+    from_string = staticmethod(from_string)
+
+    def __str__(self):
+        return self.to_string()
+
+# OAuthRequest represents the request and can be serialized
+class OAuthRequest(object):
+    '''
+    OAuth parameters:
+        - oauth_consumer_key 
+        - oauth_token
+        - oauth_signature_method
+        - oauth_signature 
+        - oauth_timestamp 
+        - oauth_nonce
+        - oauth_version
+        ... any additional parameters, as defined by the Service Provider.
+    '''
+    parameters = None # oauth parameters
+    http_method = HTTP_METHOD
+    http_url = None
+    version = VERSION
+
+    def __init__(self, http_method=HTTP_METHOD, http_url=None, parameters=None):
+        self.http_method = http_method
+        self.http_url = http_url
+        self.parameters = parameters or {}
+
+    def set_parameter(self, parameter, value):
+        self.parameters[parameter] = value
+
+    def get_parameter(self, parameter):
+        try:
+            return self.parameters[parameter]
+        except:
+            raise OAuthError('Parameter not found: %s' % parameter)
+
+    def _get_timestamp_nonce(self):
+        return self.get_parameter('oauth_timestamp'), self.get_parameter('oauth_nonce')
+
+    # get any non-oauth parameters
+    def get_nonoauth_parameters(self):
+        parameters = {}
+        for k, v in self.parameters.iteritems():
+            # ignore oauth parameters
+            if k.find('oauth_') < 0:
+                parameters[k] = v
+        return parameters
+
+    # serialize as a header for an HTTPAuth request
+    def to_header(self, realm=''):
+        auth_header = 'OAuth realm="%s"' % realm
+        # add the oauth parameters
+        if self.parameters:
+            for k, v in self.parameters.iteritems():
+                if k[:6] == 'oauth_':
+                    auth_header += ', %s="%s"' % (k, escape(str(v)))
+        return {'Authorization': auth_header}
+
+    # serialize as post data for a POST request
+    def to_postdata(self):
+        return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) for k, v in self.parameters.iteritems()])
+
+    # serialize as a url for a GET request
+    def to_url(self):
+        return '%s?%s' % (self.get_normalized_http_url(), self.to_postdata())
+
+    # return a string that consists of all the parameters that need to be signed
+    def get_normalized_parameters(self):
+        params = self.parameters
+        try:
+            # exclude the signature if it exists
+            del params['oauth_signature']
+        except:
+            pass
+        key_values = params.items()
+        # sort lexicographically, first after key, then after value
+        key_values.sort()
+        # combine key value pairs in string and escape
+        return '&'.join(['%s=%s' % (escape(str(k)), escape(str(v))) for k, v in key_values])
+
+    # just uppercases the http method
+    def get_normalized_http_method(self):
+        return self.http_method.upper()
+
+    # parses the url and rebuilds it to be scheme://host/path
+    def get_normalized_http_url(self):
+        parts = urlparse.urlparse(self.http_url)
+        url_string = '%s://%s%s' % (parts[0], parts[1], parts[2]) # scheme, netloc, path
+        return url_string
+        
+    # set the signature parameter to the result of build_signature
+    def sign_request(self, signature_method, consumer, token):
+        # set the signature method
+        self.set_parameter('oauth_signature_method', signature_method.get_name())
+        # set the signature
+        self.set_parameter('oauth_signature', self.build_signature(signature_method, consumer, token))
+
+    def build_signature(self, signature_method, consumer, token):
+        # call the build signature method within the signature method
+        return signature_method.build_signature(self, consumer, token)
+
+    def from_request(http_method, http_url, headers=None, parameters=None, query_string=None):
+        # combine multiple parameter sources
+        if parameters is None:
+            parameters = {}
+
+        # headers
+        if headers and 'Authorization' in headers:
+            auth_header = headers['Authorization']
+            # check that the authorization header is OAuth
+            if auth_header.index('OAuth') > -1:
+                try:
+                    # get the parameters from the header
+                    header_params = OAuthRequest._split_header(auth_header)
+                    parameters.update(header_params)
+                except:
+                    raise OAuthError('Unable to parse OAuth parameters from Authorization header.')
+
+        # GET or POST query string
+        if query_string:
+            query_params = OAuthRequest._split_url_string(query_string)
+            parameters.update(query_params)
+
+        # URL parameters
+        param_str = urlparse.urlparse(http_url)[4] # query
+        url_params = OAuthRequest._split_url_string(param_str)
+        parameters.update(url_params)
+
+        if parameters:
+            return OAuthRequest(http_method, http_url, parameters)
+
+        return None
+    from_request = staticmethod(from_request)
+
+    def from_consumer_and_token(oauth_consumer, token=None, http_method=HTTP_METHOD, http_url=None, parameters=None):
+        if not parameters:
+            parameters = {}
+
+        defaults = {
+            'oauth_consumer_key': oauth_consumer.key,
+            'oauth_timestamp': generate_timestamp(),
+            'oauth_nonce': generate_nonce(),
+            'oauth_version': OAuthRequest.version,
+        }
+
+        defaults.update(parameters)
+        parameters = defaults
+
+        if token:
+            parameters['oauth_token'] = token.key
+
+        return OAuthRequest(http_method, http_url, parameters)
+    from_consumer_and_token = staticmethod(from_consumer_and_token)
+
+    def from_token_and_callback(token, callback=None, http_method=HTTP_METHOD, http_url=None, parameters=None):
+        if not parameters:
+            parameters = {}
+
+        parameters['oauth_token'] = token.key
+
+        if callback:
+            parameters['oauth_callback'] = callback
+
+        return OAuthRequest(http_method, http_url, parameters)
+    from_token_and_callback = staticmethod(from_token_and_callback)
+
+    # util function: turn Authorization: header into parameters, has to do some unescaping
+    def _split_header(header):
+        params = {}
+        parts = header.split(',')
+        for param in parts:
+            # ignore realm parameter
+            if param.find('OAuth realm') > -1:
+                continue
+            # remove whitespace
+            param = param.strip()
+            # split key-value
+            param_parts = param.split('=', 1)
+            # remove quotes and unescape the value
+            params[param_parts[0]] = urllib.unquote(param_parts[1].strip('\"'))
+        return params
+    _split_header = staticmethod(_split_header)
+    
+    # util function: turn url string into parameters, has to do some unescaping
+    def _split_url_string(param_str):
+        parameters = cgi.parse_qs(param_str, keep_blank_values=False)
+        for k, v in parameters.iteritems():
+            parameters[k] = urllib.unquote(v[0])
+        return parameters
+    _split_url_string = staticmethod(_split_url_string)
+
+# OAuthServer is a worker to check a requests validity against a data store
+class OAuthServer(object):
+    timestamp_threshold = 300 # in seconds, five minutes
+    version = VERSION
+    signature_methods = None
+    data_store = None
+
+    def __init__(self, data_store=None, signature_methods=None):
+        self.data_store = data_store
+        self.signature_methods = signature_methods or {}
+
+    def set_data_store(self, oauth_data_store):
+        self.data_store = data_store
+
+    def get_data_store(self):
+        return self.data_store
+
+    def add_signature_method(self, signature_method):
+        self.signature_methods[signature_method.get_name()] = signature_method
+        return self.signature_methods
+
+    # process a request_token request
+    # returns the request token on success
+    def fetch_request_token(self, oauth_request):
+        try:
+            # get the request token for authorization
+            token = self._get_token(oauth_request, 'request')
+        except OAuthError:
+            # no token required for the initial token request
+            version = self._get_version(oauth_request)
+            consumer = self._get_consumer(oauth_request)
+            self._check_signature(oauth_request, consumer, None)
+            # fetch a new token
+            token = self.data_store.fetch_request_token(consumer)
+        return token
+
+    # process an access_token request
+    # returns the access token on success
+    def fetch_access_token(self, oauth_request):
+        version = self._get_version(oauth_request)
+        consumer = self._get_consumer(oauth_request)
+        # get the request token
+        token = self._get_token(oauth_request, 'request')
+        self._check_signature(oauth_request, consumer, token)
+        new_token = self.data_store.fetch_access_token(consumer, token)
+        return new_token
+
+    # verify an api call, checks all the parameters
+    def verify_request(self, oauth_request):
+        # -> consumer and token
+        version = self._get_version(oauth_request)
+        consumer = self._get_consumer(oauth_request)
+        # get the access token
+        token = self._get_token(oauth_request, 'access')
+        self._check_signature(oauth_request, consumer, token)
+        parameters = oauth_request.get_nonoauth_parameters()
+        return consumer, token, parameters
+
+    # authorize a request token
+    def authorize_token(self, token, user):
+        return self.data_store.authorize_request_token(token, user)
+    
+    # get the callback url
+    def get_callback(self, oauth_request):
+        return oauth_request.get_parameter('oauth_callback')
+
+    # optional support for the authenticate header   
+    def build_authenticate_header(self, realm=''):
+        return {'WWW-Authenticate': 'OAuth realm="%s"' % realm}
+
+    # verify the correct version request for this server
+    def _get_version(self, oauth_request):
+        try:
+            version = oauth_request.get_parameter('oauth_version')
+        except:
+            version = VERSION
+        if version and version != self.version:
+            raise OAuthError('OAuth version %s not supported.' % str(version))
+        return version
+
+    # figure out the signature with some defaults
+    def _get_signature_method(self, oauth_request):
+        try:
+            signature_method = oauth_request.get_parameter('oauth_signature_method')
+        except:
+            signature_method = SIGNATURE_METHOD
+        try:
+            # get the signature method object
+            signature_method = self.signature_methods[signature_method]
+        except:
+            signature_method_names = ', '.join(self.signature_methods.keys())
+            raise OAuthError('Signature method %s not supported try one of the following: %s' % (signature_method, signature_method_names))
+
+        return signature_method
+
+    def _get_consumer(self, oauth_request):
+        consumer_key = oauth_request.get_parameter('oauth_consumer_key')
+        if not consumer_key:
+            raise OAuthError('Invalid consumer key.')
+        consumer = self.data_store.lookup_consumer(consumer_key)
+        if not consumer:
+            raise OAuthError('Invalid consumer.')
+        return consumer
+
+    # try to find the token for the provided request token key
+    def _get_token(self, oauth_request, token_type='access'):
+        token_field = oauth_request.get_parameter('oauth_token')
+        token = self.data_store.lookup_token(token_type, token_field)
+        if not token:
+            raise OAuthError('Invalid %s token: %s' % (token_type, token_field))
+        return token
+
+    def _check_signature(self, oauth_request, consumer, token):
+        timestamp, nonce = oauth_request._get_timestamp_nonce()
+        self._check_timestamp(timestamp)
+        self._check_nonce(consumer, token, nonce)
+        signature_method = self._get_signature_method(oauth_request)
+        try:
+            signature = oauth_request.get_parameter('oauth_signature')
+        except:
+            raise OAuthError('Missing signature.')
+        # validate the signature
+        valid_sig = signature_method.check_signature(oauth_request, consumer, token, signature)
+        if not valid_sig:
+            key, base = signature_method.build_signature_base_string(oauth_request, consumer, token)
+            raise OAuthError('Invalid signature. Expected signature base string: %s' % base)
+        built = signature_method.build_signature(oauth_request, consumer, token)
+
+    def _check_timestamp(self, timestamp):
+        # verify that timestamp is recentish
+        timestamp = int(timestamp)
+        now = int(time.time())
+        lapsed = now - timestamp
+        if lapsed > self.timestamp_threshold:
+            raise OAuthError('Expired timestamp: given %d and now %s has a greater difference than threshold %d' % (timestamp, now, self.timestamp_threshold))
+
+    def _check_nonce(self, consumer, token, nonce):
+        # verify that the nonce is uniqueish
+        nonce = self.data_store.lookup_nonce(consumer, token, nonce)
+        if nonce:
+            raise OAuthError('Nonce already used: %s' % str(nonce))
+
+# OAuthClient is a worker to attempt to execute a request
+class OAuthClient(object):
+    consumer = None
+    token = None
+
+    def __init__(self, oauth_consumer, oauth_token):
+        self.consumer = oauth_consumer
+        self.token = oauth_token
+
+    def get_consumer(self):
+        return self.consumer
+
+    def get_token(self):
+        return self.token
+
+    def fetch_request_token(self, oauth_request):
+        # -> OAuthToken
+        raise NotImplementedError
+
+    def fetch_access_token(self, oauth_request):
+        # -> OAuthToken
+        raise NotImplementedError
+
+    def access_resource(self, oauth_request):
+        # -> some protected resource
+        raise NotImplementedError
+
+# OAuthDataStore is a database abstraction used to lookup consumers and tokens
+class OAuthDataStore(object):
+
+    def lookup_consumer(self, key):
+        # -> OAuthConsumer
+        raise NotImplementedError
+
+    def lookup_token(self, oauth_consumer, token_type, token_token):
+        # -> OAuthToken
+        raise NotImplementedError
+
+    def lookup_nonce(self, oauth_consumer, oauth_token, nonce, timestamp):
+        # -> OAuthToken
+        raise NotImplementedError
+
+    def fetch_request_token(self, oauth_consumer):
+        # -> OAuthToken
+        raise NotImplementedError
+
+    def fetch_access_token(self, oauth_consumer, oauth_token):
+        # -> OAuthToken
+        raise NotImplementedError
+
+    def authorize_request_token(self, oauth_token, user):
+        # -> OAuthToken
+        raise NotImplementedError
+
+# OAuthSignatureMethod is a strategy class that implements a signature method
+class OAuthSignatureMethod(object):
+    def get_name(self):
+        # -> str
+        raise NotImplementedError
+
+    def build_signature_base_string(self, oauth_request, oauth_consumer, oauth_token):
+        # -> str key, str raw
+        raise NotImplementedError
+
+    def build_signature(self, oauth_request, oauth_consumer, oauth_token):
+        # -> str
+        raise NotImplementedError
+
+    def check_signature(self, oauth_request, consumer, token, signature):
+        built = self.build_signature(oauth_request, consumer, token)
+        return built == signature
+
+class OAuthSignatureMethod_HMAC_SHA1(OAuthSignatureMethod):
+
+    def get_name(self):
+        return 'HMAC-SHA1'
+        
+    def build_signature_base_string(self, oauth_request, consumer, token):
+        sig = (
+            escape(oauth_request.get_normalized_http_method()),
+            escape(oauth_request.get_normalized_http_url()),
+            escape(oauth_request.get_normalized_parameters()),
+        )
+
+        key = '%s&' % escape(consumer.secret)
+        if token:
+            key += escape(token.secret)
+        raw = '&'.join(sig)
+        return key, raw
+
+    def build_signature(self, oauth_request, consumer, token):
+        # build the base signature string
+        key, raw = self.build_signature_base_string(oauth_request, consumer, token)
+
+        # hmac object
+        try:
+            import hashlib # 2.5
+            hashed = hmac.new(key, raw, hashlib.sha1)
+        except:
+            import sha # deprecated
+            hashed = hmac.new(key, raw, sha)
+
+        # calculate the digest base 64
+        return binascii.b2a_base64(hashed.digest())[:-1]
+
+class OAuthSignatureMethod_PLAINTEXT(OAuthSignatureMethod):
+
+    def get_name(self):
+        return 'PLAINTEXT'
+
+    def build_signature_base_string(self, oauth_request, consumer, token):
+        # concatenate the consumer key and secret
+        sig = escape(consumer.secret) + '&'
+        if token:
+            sig = sig + escape(token.secret)
+        return sig
+
+    def build_signature(self, oauth_request, consumer, token):
+        return self.build_signature_base_string(oauth_request, consumer, token)
diff --git a/social_auth/store.py b/social_auth/store.py
new file mode 100644 (file)
index 0000000..b5db453
--- /dev/null
@@ -0,0 +1,57 @@
+import time
+import base64
+
+from openid.association import Association as OIDAssociation
+from openid.store.interface import OpenIDStore
+from openid.store.nonce import SKEW
+
+from .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):
+        args = {'server_url': server_url, 'handle': association.handle}
+        try:
+            assoc = Association.objects.get(**args)
+        except Association.DoesNotExist:
+            assoc = Association(**args)
+        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):
+        args = {'server_url': server_url}
+        if handle is not None:
+            args['handle'] = handle
+
+        associations, expired = [], []
+        for assoc in Association.objects.filter(**args):
+            association = OIDAssociation(assoc.handle,
+                                         base64.decodestring(assoc.secret),
+                                         assoc.issued,
+                                         assoc.lifetime,
+                                         assoc.assoc_type)
+            if association.getExpiresIn() == 0:
+                expired.append(assoc.id)
+            else:
+                associations.append(association)
+
+        if expired: # clear expired associations
+            Association.objects.filter(pk__in=expired).delete()
+
+        if associations:
+            associations.sort(key=lambda x: x.issued, reverse=True)
+            return associations[0]
+
+    def useNonce(self, server_url, timestamp, salt):
+        if abs(timestamp - time.time()) > SKEW:
+            return False
+        nonce, created = Nonce.objects.get_or_create(server_url=server_url,
+                                                     timestamp=timestamp,
+                                                     salt=salt)
+        return created
diff --git a/social_auth/urls.py b/social_auth/urls.py
new file mode 100644 (file)
index 0000000..1572f42
--- /dev/null
@@ -0,0 +1,14 @@
+from django.conf.urls.defaults import patterns, url
+
+from .views import home, done, logout, auth, complete
+
+
+urlpatterns = patterns('',
+    url(r'^login/(?P<backend>[^/]+)/$', auth, name='begin'), 
+    url(r'^complete/(?P<backend>[^/]+)/$', complete, name='complete'), 
+
+    # demo urls
+    url(r'^$', home, name='home'), 
+    url(r'^done/$', done, name='done'), 
+    url(r'^logout/$', logout, name='logout'), 
+)
diff --git a/social_auth/views.py b/social_auth/views.py
new file mode 100644 (file)
index 0000000..1a3cc7b
--- /dev/null
@@ -0,0 +1,76 @@
+from django.conf import settings
+from django.http import HttpResponseRedirect, HttpResponse, \
+                        HttpResponseServerError
+from django.core.urlresolvers import reverse
+from django.contrib.auth import login, logout as auth_logout, REDIRECT_FIELD_NAME
+from django.contrib.auth.decorators import login_required
+
+from .twitter import TwitterOAuth
+from .facebook import FacebookOAuth
+from .openid_auth import OpenIDAuth, GoogleAuth, YahooAuth
+
+
+BACKENDS = {
+    'twitter': TwitterOAuth,
+    'facebook': FacebookOAuth,
+    'google': GoogleAuth,
+    'yahoo': YahooAuth,
+    'openid': OpenIDAuth,
+}
+
+
+def auth(request, backend):
+    if backend not in BACKENDS:
+        return HttpResponseServerError('Incorrect authentication service')
+    request.session[REDIRECT_FIELD_NAME] = request.GET.get(REDIRECT_FIELD_NAME,
+                                                           settings.LOGIN_REDIRECT_URL)
+    redirect = reverse('social:complete', args=(backend,))
+    backend = BACKENDS[backend](request, redirect)
+    if backend.uses_redirect:
+        return HttpResponseRedirect(backend.auth_url())
+    else:
+        return HttpResponse(backend.auth_html(),
+                            content_type='text/html;charset=UTF-8')
+
+
+def complete(request, backend):
+    if backend not in BACKENDS:
+        return HttpResponseServerError('Incorrect authentication service')
+    backend = BACKENDS[backend](request, request.path)
+    user = backend.auth_complete()
+    if user and user.is_active:
+        login(request, user)
+    return HttpResponseRedirect(request.session.pop(REDIRECT_FIELD_NAME,
+                                                    settings.LOGIN_REDIRECT_URL))
+
+
+def home(request):
+    return HttpResponse(
+    """
+    <div>
+      <h2>OAuth</h2>
+      <a href="/login/twitter/">twitter</a>
+      <a href="/login/facebook/">facebook</a>
+    </div>
+
+    <div>
+      <h2>OPenID</h2>
+      <a href="/login/google/">google</a>
+      <a href="/login/yahoo/">yahoo</a>
+      <form action="/login/openid/" method="post">
+        provider: <input type="text" value="" name="openid_identifier" />
+        <input type="submit" />
+      </form>
+    </div>
+    <br />
+    <a href="/logout">logout</a>
+    """, content_type='text/html;charset=UTF-8')
+
+@login_required
+def done(request):
+    user = request.user
+    return HttpResponse('%s / %s / %s' % (user.id, user.username, user.first_name))
+
+def logout(request):
+    auth_logout(request)
+    return HttpResponse('logged out')