From 3532953cdc62f4f084c36808d8f05e12ce7fb74a Mon Sep 17 00:00:00 2001 From: =?utf8?q?Mat=C3=ADas=20Aguirre?= Date: Thu, 25 Nov 2010 15:48:28 -0200 Subject: [PATCH] Association of multiple social credentials to single user account support, Closes gh-5 --- README.rst | 39 +++++++++++++++++++++--- example/app/views.py | 21 +++++++++++-- example/urls.py | 2 +- social_auth/auth.py | 30 ++++++++++--------- social_auth/backends.py | 2 +- social_auth/base.py | 66 ++++++++++++++++++++++------------------- social_auth/models.py | 2 +- social_auth/urls.py | 9 ++++-- social_auth/views.py | 66 +++++++++++++++++++++++++++++------------ 9 files changed, 161 insertions(+), 76 deletions(-) diff --git a/README.rst b/README.rst index 7c28662..e1cce53 100644 --- a/README.rst +++ b/README.rst @@ -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/ diff --git a/example/app/views.py b/example/app/views.py index 07af2bc..57cf051 100644 --- a/example/app/views.py +++ b/example/app/views.py @@ -44,7 +44,6 @@ def home(request): @login_required def done(request): - user = request.user return HttpResponse(Template( """ @@ -62,9 +61,27 @@ def done(request): Last name: {{ user.last_name|default:"Not provided" }}

Logout

+ +

Associate new credentials:

+
+ +
- """).render(Context({'user':user})), + """).render(RequestContext(request)), content_type='text/html;charset=UTF-8') diff --git a/example/urls.py b/example/urls.py index 35c5382..7bd6d6f 100644 --- a/example/urls.py +++ b/example/urls.py @@ -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')), ) diff --git a/social_auth/auth.py b/social_auth/auth.py index a17a58f..9192299 100644 --- a/social_auth/auth.py +++ b/social_auth/auth.py @@ -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' diff --git a/social_auth/backends.py b/social_auth/backends.py index 724fe0d..1fef420 100644 --- a/social_auth/backends.py +++ b/social_auth/backends.py @@ -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' diff --git a/social_auth/base.py b/social_auth/base.py index b008461..dd5f00d 100644 --- a/social_auth/base.py +++ b/social_auth/base.py @@ -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 diff --git a/social_auth/models.py b/social_auth/models.py index 1352d90..84469b0 100644 --- a/social_auth/models.py +++ b/social_auth/models.py @@ -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: diff --git a/social_auth/urls.py b/social_auth/urls.py index fb595af..2185499 100644 --- a/social_auth/urls.py +++ b/social_auth/urls.py @@ -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[^/]+)/$', auth, name='begin'), - url(r'^complete/(?P[^/]+)/$', complete, name='complete'), + url(r'^login/(?P[^/]+)/$', auth, name='begin'), + url(r'^complete/(?P[^/]+)/$', complete, name='complete'), + url(r'^associate/(?P[^/]+)/$', associate, name='associate_begin'), + url(r'^associate/complete/(?P[^/]+)/$', associate_complete, + name='associate_complete'), ) diff --git a/social_auth/views.py b/social_auth/views.py index c791ede..bb47689 100644 --- a/social_auth/views.py +++ b/social_auth/views.py @@ -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') -- 2.39.5