]> git.parisson.com Git - django-social-auth.git/commitdiff
Association of multiple social credentials to single user account support, Closes...
authorMatías Aguirre <matiasaguirre@gmail.com>
Thu, 25 Nov 2010 17:48:28 +0000 (15:48 -0200)
committerMatías Aguirre <matiasaguirre@gmail.com>
Thu, 25 Nov 2010 17:48:28 +0000 (15:48 -0200)
README.rst
example/app/views.py
example/urls.py
social_auth/auth.py
social_auth/backends.py
social_auth/base.py
social_auth/models.py
social_auth/urls.py
social_auth/views.py

index 7c28662d4ac1abbe0e1dbae1dc7f656670807cfb..e1cce535561c08ae0cf9c20c46ec4fdb474d2990 100644 (file)
@@ -10,6 +10,29 @@ implements a common interface to define new authentication providers from
 third parties.
 
 
+--------
+Features
+--------
+This app provides user registration and login using social sites credetials,
+some features are:
+
+- Registration and Login using social sites using the following providers
+  at the moment:
+
+    * `Google OpenID`_
+    * `Yahoo OpenID`_
+    * OpenID like myOpenID_
+    * `Twitter OAuth`_
+    * `Facebook OAuth`_
+    * `Orkut OAuth`_
+
+- Basic user data population
+
+- Multiple social accounts association to single users
+
+- Custom User model override if needed (`auth.User`_ by default)
+
+
 ------------
 Dependencies
 ------------
@@ -60,9 +83,11 @@ Installation
 
   Check Django documentation at `Login url`_ and `Login redirect url`_
 
-- Configure complete url name to avoid possible clashes::
+- Configure authentication and association complete URL names to avoid
+  possible clashes::
 
-    SOCIAL_AUTH_COMPLETE_URL_NAME = 'namespace:complete'
+    SOCIAL_AUTH_COMPLETE_URL_NAME  = 'namespace:complete'
+    SOCIAL_AUTH_ASSOCIATE_URL_NAME = 'namespace:association_complete'
 
 - Add urls entries::
 
@@ -122,7 +147,7 @@ Installation
 ------
 OpenId
 ------
-OpenId support is simpler to implement than OAuth, by Google and Yahoo 
+OpenId support is simpler to implement than OAuth, by Google and Yahoo
 providers are supported by default, others are supported by POST method
 providing endpoint Url.
 
@@ -196,7 +221,7 @@ Contributors
 ------------
 Attributions to whom deserves:
 
-- caioariede_ (Caio Ariede) 
+- caioariede_ (Caio Ariede)
 
 
 ----------
@@ -232,3 +257,9 @@ Base work is copyrighted by:
 .. _caioariede: https://github.com/caioariede
 .. _Google support: http://www.google.com/support/a/bin/answer.py?hl=en&answer=162105
 .. _Orkut API:  http://code.google.com/apis/orkut/docs/rest/developers_guide_protocol.html#Authenticating
+.. _Google OpenID: http://code.google.com/apis/accounts/docs/OpenID.html
+.. _Yahoo OpenID: http://openid.yahoo.com/
+.. _Twitter OAuth: http://dev.twitter.com/pages/oauth_faq
+.. _Facebook OAuth: http://developers.facebook.com/docs/authentication/
+.. _Orkut OAuth:  http://code.google.com/apis/orkut/docs/rest/developers_guide_protocol.html#Authenticating
+.. _myOpenID: https://www.myopenid.com/
index 07af2bcb1e085047bd5effa713484f1d3abfcd00..57cf0517b9467991d09e39ce4750ac20b65c944a 100644 (file)
@@ -44,7 +44,6 @@ def home(request):
 
 @login_required
 def done(request):
-    user = request.user
     return HttpResponse(Template(
     """
     <html>
@@ -62,9 +61,27 @@ def done(request):
           <tr><th>Last name:</th> <td>{{ user.last_name|default:"Not provided" }}</td></tr>
         </table>
         <p><a href="/logout/">Logout</a></p>
+
+        <h2>Associate new credentials:</h2>
+        <div>
+          <ul>
+            <li><a href="/associate/twitter/">Twitter</a></li>
+            <li><a href="/associate/facebook/">Facebook</a></li>
+            <li><a href="/associate/orkut/">Orkut</a></li>
+            <li><a href="/associate/google/">Google</a></li>
+            <li><a href="/associate/yahoo/">Yahoo</a></li>
+            <li>
+              <form action="/associate/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>
       </body>
     </html>
-    """).render(Context({'user':user})),
+    """).render(RequestContext(request)),
     content_type='text/html;charset=UTF-8')
 
 
index 35c5382bf5c823bef9d684f1a00380c27c6a25b4..7bd6d6f5f4e1ff671c6025528d828fbc9ec61d1b 100644 (file)
@@ -11,6 +11,6 @@ urlpatterns = patterns('',
     url(r'^done/$', done, name='done'), 
     url(r'^error/$', error, name='error'),
     url(r'^logout/$', logout, name='logout'), 
-    url(r'', include('social_auth.urls', namespace='social')),
     url(r'^admin/', include(admin.site.urls)),
+    url(r'', include('social_auth.urls', namespace='social')),
 )
index a17a58fae87a51ab3d147c7222640b0d38883d1a..91922993cc0effdeabc27cfdde10558e5013ef2c 100644 (file)
@@ -47,14 +47,14 @@ class OpenIdAuth(BaseAuth):
         return openid_request.htmlMarkup(trust_root, return_to,
                                          form_tag_attrs=form_tag)
 
-    def auth_complete(self):
+    def auth_complete(self, *args, **kwargs):
         response = self.consumer().complete(dict(self.request.REQUEST.items()),
                                             self.request.build_absolute_uri())
         if not response:
             raise ValueError, 'This is an OpenID relying party endpoint'
         elif response.status == SUCCESS:
-            return authenticate(**{'response': response,
-                                   OpenIDBackend.name: True})
+            kwargs.update({'response': response, OpenIDBackend.name: True})
+            return authenticate(*args, **kwargs)
         elif response.status == FAILURE:
             raise ValueError, 'OpenID authentication failed: %s' % response.message
         elif response.status == CANCEL:
@@ -159,7 +159,7 @@ class ConsumerBasedOAuth(BaseOAuth):
         self.request.session[name] = token.to_string()
         return self.oauth_request(token, self.AUTHORIZATION_URL).to_url()
 
-    def auth_complete(self):
+    def auth_complete(self, *args, **kwargs):
         """Returns user, might be logged in"""
         name = self.AUTH_BACKEND.name + 'unauthorized_token_name'
         unauthed_token = self.request.session.get(name)
@@ -175,7 +175,8 @@ class ConsumerBasedOAuth(BaseOAuth):
         if data is not None:
             data['access_token'] = access_token.to_string()
 
-        return authenticate(**{'response': data, self.AUTH_BACKEND.name: True})
+        kwargs.update({'response': data, self.AUTH_BACKEND.name: True})
+        return authenticate(*args, **kwargs)
 
     def unauthorized_token(self):
         """Return request for unauthorized token (first stage)"""
@@ -307,24 +308,25 @@ class FacebookAuth(BaseOAuth):
                 'redirect_uri': self.redirect_uri}
         return FACEBOOK_AUTHORIZATION_URL + '?' + urllib.urlencode(args)
 
-    def auth_complete(self):
+    def auth_complete(self, *args, **kwargs):
         """Returns user, might be logged in"""
         if 'code' in self.request.GET:
-            args = {'client_id': settings.FACEBOOK_APP_ID,
-                    'redirect_uri': self.redirect_uri,
-                    'client_secret': settings.FACEBOOK_API_SECRET,
-                    'code': self.request.GET['code']}
-            url = FACEBOOK_ACCESS_TOKEN_URL + '?' + urllib.urlencode(args)
+            url = FACEBOOK_ACCESS_TOKEN_URL + '?' + \
+                  urllib.urlencode({'client_id': settings.FACEBOOK_APP_ID,
+                                'redirect_uri': self.redirect_uri,
+                                'client_secret': settings.FACEBOOK_API_SECRET,
+                                'code': self.request.GET['code']})
             response = cgi.parse_qs(urllib.urlopen(url).read())
-        
+
             access_token = response['access_token'][0]
             data = self.user_data(access_token)
             if data is not None:
                 if 'error' in data:
                     raise ValueError, 'Authentication error'
                 data['access_token'] = access_token
-            return authenticate(**{'response': data,
-                                   FacebookBackend.name: True})
+
+            kwargs.update({'response': data, FacebookBackend.name: True})
+            return authenticate(*args, **kwargs)
         else:
             raise ValueError, 'Authentication error'
 
index 724fe0dbe9d04525eebb956bbd8eb8097654a757..1fef420ea795a5b694d412c16da2a708f7cf31c0 100644 (file)
@@ -56,7 +56,7 @@ class FacebookBackend(OAuthBackend):
                 'first_name': response.get('first_name', ''),
                 'last_name': response.get('last_name', '')}
 
-       
+
 class OpenIDBackend(SocialAuthBackend):
     """Generic OpenID authentication backend"""
     name = 'openid'
index b00846146c8313f7eabd2b7afa756df1d4a8ab7c..dd5f00d17f538265aa080ca56b5fe109369f134e 100644 (file)
@@ -22,12 +22,12 @@ class BaseAuth(object):
     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):
+
+    def auth_complete(self, *args, **kwargs):
         """Completes loging process, must return user instance"""
         raise NotImplementedError, 'Implement in subclass'
 
@@ -43,14 +43,13 @@ class SocialAuthBackend(ModelBackend):
     a authentication provider response"""
     name = '' # provider name, it's stored in database
 
-    def authenticate(self, **kwargs):
+    def authenticate(self, *args, **kwargs):
         """Authenticate user using social credentials
 
         Authentication is made if this is the correct backend, backend
         verification is made by kwargs inspection for current backend
         name presence.
         """
-
         # Validate backend and arguments. 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.
@@ -67,12 +66,12 @@ class SocialAuthBackend(ModelBackend):
         except UserSocialAuth.DoesNotExist:
             if not getattr(settings, 'SOCIAL_AUTH_CREATE_USERS', False):
                 return None
-            user = self.create_user(response, details)
+            user = self.create_user(details=details, *args, **kwargs)
         else:
             user = auth_user.user
             self.update_user_details(user, details)
         return user
-    
+
     def get_username(self, details):
         """Return an unique username, if SOCIAL_AUTH_FORCE_RANDOM_USERNAME
         setting is True, then username will be a random 30 chars md5 hash
@@ -103,19 +102,23 @@ class SocialAuthBackend(ModelBackend):
                 break
         return username
 
-    def create_user(self, response, details):
-        """Create user with unique username"""
-        username = self.get_username(details)
-        email = details.get('email', '')
-
-        if hasattr(User.objects, 'create_user'): # auth.User
-            user = User.objects.create_user(username, email)
-        else: # create user setting password to an unusable value
-            user = User.objects.create(username=username, email=email,
-                                       password=UNUSABLE_PASSWORD)
-
-        self.update_user_details(user, details) # load details
-        self.associate_auth(user, response, details) # save account association
+    def create_user(self, response, details, *args, **kwargs):
+        """Create user with unique username. New social credentials are
+        associated with @user if this parameter is not None."""
+        user = kwargs.get('user')
+        if user is None: # create user, otherwise associate the new credential
+            username = self.get_username(details)
+            email = details.get('email', '')
+
+            if hasattr(User.objects, 'create_user'): # auth.User
+                user = User.objects.create_user(username, email)
+            else: # create user setting password to an unusable value
+                user = User.objects.create(username=username, email=email,
+                                           password=UNUSABLE_PASSWORD)
+
+        # update details and associate account with social credentials
+        self.update_user_details(user, details)
+        self.associate_auth(user, response, details)
         return user
 
     def associate_auth(self, user, response, details):
@@ -123,21 +126,21 @@ class SocialAuthBackend(ModelBackend):
         # 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)
+            user_social = UserSocialAuth.objects.select_related('user')\
+                                                .get(provider=self.name,
+                                                     uid=uid)
         except UserSocialAuth.DoesNotExist:
             if getattr(settings, 'SOCIAL_AUTH_EXTRA_DATA', True):
                 extra_data = self.extra_data(user, uid, response, details)
             else:
                 extra_data = ''
-            user_oauth = UserSocialAuth.objects.create(user=user, uid=uid,
-                                                       provider=self.name,
-                                                       extra_data=extra_data)
+            user_social = UserSocialAuth.objects.create(user=user, uid=uid,
+                                                        provider=self.name,
+                                                        extra_data=extra_data)
         else:
-            if user_oauth.user != user:
+            if user_social.user != user:
                 raise ValueError, 'Identity already claimed'
-        return user_oauth
+        return user_social
 
     def extra_data(self, user, uid, response, details):
         """Return default blank user extra data"""
@@ -145,12 +148,13 @@ class SocialAuthBackend(ModelBackend):
 
     def update_user_details(self, user, details):
         """Update user details with new (maybe) data"""
-        fields = user._meta.get_all_field_names()
+        fields = (name for name in ('first_name', 'last_name', 'email')
+                        if user._meta.get_field(name))
         changed = False
 
-        for name in ('first_name', 'last_name', 'email'):
+        for name in fields:
             value = details.get(name)
-            if name in fields and value != getattr(user, name, value):
+            if value and value != getattr(user, name, value):
                 setattr(user, name, value)
                 changed = True
 
index 1352d90e452e63ed3d9df0948f2dd476d520c6dc..84469b00f32a731e76b13290608010ffa25494f1 100644 (file)
@@ -1,5 +1,5 @@
 """Social auth models"""
-from django.db import models 
+from django.db import models
 from django.conf import settings
 
 # If User class is overrided, it must provide the following fields:
index fb595affe81b29ae0e7d05a74d9570761ff7fb28..21854998c73d0b1b2cda5f6a6ef53267c7fd8f6f 100644 (file)
@@ -1,10 +1,13 @@
 """URLs module"""
 from django.conf.urls.defaults import patterns, url
 
-from .views import auth, complete
+from .views import auth, complete, associate, associate_complete
 
 
 urlpatterns = patterns('',
-    url(r'^login/(?P<backend>[^/]+)/$', auth, name='begin'), 
-    url(r'^complete/(?P<backend>[^/]+)/$', complete, name='complete'), 
+    url(r'^login/(?P<backend>[^/]+)/$', auth, name='begin'),
+    url(r'^complete/(?P<backend>[^/]+)/$', complete, name='complete'),
+    url(r'^associate/(?P<backend>[^/]+)/$', associate, name='associate_begin'),
+    url(r'^associate/complete/(?P<backend>[^/]+)/$', associate_complete,
+        name='associate_complete'),
 )
index c791ede9bf94386788b5af9d594583cf8a3586b1..bb476891bdf5430b3a18f0a5f31ac4b05cedc2a0 100644 (file)
@@ -4,11 +4,13 @@ from django.http import HttpResponseRedirect, HttpResponse, \
                         HttpResponseServerError
 from django.core.urlresolvers import reverse
 from django.contrib.auth import login, REDIRECT_FIELD_NAME
+from django.contrib.auth.decorators import login_required
 
-from .auth import TwitterAuth, FacebookAuth, OpenIdAuth, GoogleAuth, YahooAuth, OrkutAuth
+from .auth import TwitterAuth, FacebookAuth, OpenIdAuth, GoogleAuth, \
+                  YahooAuth, OrkutAuth
 
 
-# Authenticatin backends
+# Authentication backends
 BACKENDS = {
     'twitter': TwitterAuth,
     'facebook': FacebookAuth,
@@ -18,22 +20,12 @@ BACKENDS = {
     'orkut': OrkutAuth,
 }
 
-def auth(request, backend):
-    """Authentication starting process"""
-    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(getattr(settings, 'SOCIAL_AUTH_COMPLETE_URL_NAME',
-                               '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 auth(request, backend):
+    """Start authentication process"""
+    complete = getattr(settings, 'SOCIAL_AUTH_COMPLETE_URL_NAME', 'complete')
+    redirect = getattr(settings, 'LOGIN_REDIRECT_URL', '')
+    return auth_process(request, backend, complete, redirect)
 
 
 def complete(request, backend):
@@ -44,8 +36,44 @@ def complete(request, backend):
     user = backend.auth_complete()
     if user and user.is_active:
         login(request, user)
-        url = request.session.pop(REDIRECT_FIELD_NAME,
-                                  settings.LOGIN_REDIRECT_URL)
+        url = request.session.pop(REDIRECT_FIELD_NAME, '') or \
+              getattr(settings, 'LOGIN_REDIRECT_URL', '')
     else:
         url = getattr(settings, 'LOGIN_ERROR_URL', settings.LOGIN_URL)
     return HttpResponseRedirect(url)
+
+
+@login_required
+def associate(request, backend):
+    """Authentication starting process"""
+    complete = getattr(settings, 'SOCIAL_AUTH_ASSOCIATE_URL_NAME',
+                       'associate_complete')
+    redirect = getattr(settings, 'LOGIN_REDIRECT_URL', '')
+    return auth_process(request, backend, complete, redirect)
+
+
+@login_required
+def associate_complete(request, backend):
+    """Authentication complete process"""
+    if backend not in BACKENDS:
+        return HttpResponseServerError('Incorrect authentication service')
+    backend = BACKENDS[backend](request, request.path)
+    user = backend.auth_complete(user=request.user)
+    url = request.session.pop(REDIRECT_FIELD_NAME, '') or \
+          getattr(settings, 'LOGIN_REDIRECT_URL', '')
+    return HttpResponseRedirect(url)
+
+
+def auth_process(request, backend, complete_url_name, default_final_url):
+    """Authenticate using social backend"""
+    if backend not in BACKENDS:
+        return HttpResponseServerError('Incorrect authentication service')
+    request.session[REDIRECT_FIELD_NAME] = request.GET.get(REDIRECT_FIELD_NAME,
+                                                           default_final_url)
+    redirect = reverse(complete_url_name, 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')