From 88cf415c9819165ee5e25bdede8f55c88fb4f1e5 Mon Sep 17 00:00:00 2001 From: =?utf8?q?Mat=C3=ADas=20Aguirre?= Date: Mon, 8 Nov 2010 08:07:59 -0200 Subject: [PATCH 1/1] Initial commit for django-social-auth --- .gitignore | 3 + COPYRIGHT | 2 + LICENCE | 27 ++ LICENCE.django-openid-auth | 27 ++ README.rst | 6 + example/__init__.py | 0 example/app/__init__.py | 0 example/app/views.py | 71 ++++ example/local_settings.py.template | 7 + example/manage.py | 11 + example/settings.py | 105 ++++++ example/social_auth | 1 + example/urls.py | 12 + social_auth/__init__.py | 0 social_auth/admin.py | 27 ++ social_auth/backends.py | 96 ++++++ social_auth/base.py | 133 ++++++++ social_auth/conf.py | 35 ++ social_auth/models.py | 26 ++ social_auth/oauth.py | 524 +++++++++++++++++++++++++++++ social_auth/store.py | 57 ++++ social_auth/urls.py | 14 + social_auth/views.py | 76 +++++ 23 files changed, 1260 insertions(+) create mode 100644 .gitignore create mode 100644 COPYRIGHT create mode 100644 LICENCE create mode 100644 LICENCE.django-openid-auth create mode 100644 README.rst create mode 100644 example/__init__.py create mode 100644 example/app/__init__.py create mode 100644 example/app/views.py create mode 100644 example/local_settings.py.template create mode 100755 example/manage.py create mode 100644 example/settings.py create mode 120000 example/social_auth create mode 100644 example/urls.py create mode 100644 social_auth/__init__.py create mode 100644 social_auth/admin.py create mode 100644 social_auth/backends.py create mode 100644 social_auth/base.py create mode 100644 social_auth/conf.py create mode 100644 social_auth/models.py create mode 100644 social_auth/oauth.py create mode 100644 social_auth/store.py create mode 100644 social_auth/urls.py create mode 100644 social_auth/views.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5e2a46f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +*.pyc +.*.sw[po] +local_settings.py diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 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 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 index 0000000..abbb525 --- /dev/null +++ b/LICENCE.django-openid-auth @@ -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 index 0000000..289c419 --- /dev/null +++ b/README.rst @@ -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 index 0000000..e69de29 diff --git a/example/app/__init__.py b/example/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/app/views.py b/example/app/views.py new file mode 100644 index 0000000..535be2a --- /dev/null +++ b/example/app/views.py @@ -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( + """ + + + Social access + + +

Login using any of the following methods:

+
+
+

Login using OAuth from:

+ +
+
+

Login using OpenId from:

+
    +
  • Google
  • +
  • Yahoo
  • +
  • +
    {% csrf_token %} + + + +
    +
  • +
+
+
+ + + """).render(Context(RequestContext(request))), + content_type='text/html;charset=UTF-8') + +@login_required +def done(request): + user = request.user + return HttpResponse(Template( + """ + + + Logged in + + + +

Logged in!

+ + + + + + +
Id: {{ user.id }}
Username: {{ user.username }}
Email: {{ user.email|default:"Not provided" }}
First name: {{ user.first_name|default:"Not provided" }}
Last name: {{ user.last_name|default:"Not provided" }}
+

Logout

+ + + """).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 index 0000000..31339d5 --- /dev/null +++ b/example/local_settings.py.template @@ -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 index 0000000..5e78ea9 --- /dev/null +++ b/example/manage.py @@ -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 index 0000000..68f62f1 --- /dev/null +++ b/example/settings.py @@ -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 index 0000000..fdc701c --- /dev/null +++ b/example/social_auth @@ -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 index 0000000..610e798 --- /dev/null +++ b/example/urls.py @@ -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 index 0000000..e69de29 diff --git a/social_auth/admin.py b/social_auth/admin.py new file mode 100644 index 0000000..d2d5d51 --- /dev/null +++ b/social_auth/admin.py @@ -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 index 0000000..9511a05 --- /dev/null +++ b/social_auth/backends.py @@ -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 index 0000000..03628ec --- /dev/null +++ b/social_auth/base.py @@ -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': , + 'username': , + 'fullname': , + 'firstname': , + 'lastname': } + """ + raise NotImplementedError, 'Implement in subclass' diff --git a/social_auth/conf.py b/social_auth/conf.py new file mode 100644 index 0000000..ef13cb7 --- /dev/null +++ b/social_auth/conf.py @@ -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 index 0000000..21baecb --- /dev/null +++ b/social_auth/models.py @@ -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 index 0000000..fd1f6a5 --- /dev/null +++ b/social_auth/oauth.py @@ -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 index 0000000..b5db453 --- /dev/null +++ b/social_auth/store.py @@ -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 index 0000000..1572f42 --- /dev/null +++ b/social_auth/urls.py @@ -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[^/]+)/$', auth, name='begin'), + url(r'^complete/(?P[^/]+)/$', 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 index 0000000..1a3cc7b --- /dev/null +++ b/social_auth/views.py @@ -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( + """ +
+

OAuth

+ twitter + facebook +
+ +
+

OPenID

+ google + yahoo +
+ provider: + +
+
+
+ logout + """, 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') -- 2.39.5