diff --git a/README.rst b/README.rst index 873b03c9c..80c08e0ba 100644 --- a/README.rst +++ b/README.rst @@ -13,8 +13,8 @@ third parties. -------- Features -------- -This app provides user registration and login using social sites credentials, -some features are: +This application provides user registration and login using social sites +credentials, some features are: - Registration and Login using social sites using the following providers at the moment: @@ -50,7 +50,7 @@ Dependencies that must be meet to use the app: ------------ Installation ------------ -- Add social_auth app to PYTHONPATH and installed apps:: +- Add social_auth to PYTHONPATH and installed applications:: INSTALLED_APPS = ( ... @@ -63,6 +63,8 @@ Installation 'social_auth.backends.TwitterOAuthBackend', 'social_auth.backends.FacebookOAuthBackend', 'social_auth.backends.OrkutOAuthBackend', + 'social_auth.backends.GoogleOpenIDBackend', + 'social_auth.backends.YahooOpenIDBackend', 'social_auth.backends.OpenIDBackend', 'django.contrib.auth.backends.ModelBackend', ) @@ -76,13 +78,13 @@ Installation ORKUT_CONSUMER_KEY = '' ORKUT_CONSUMER_SECRET = '' -- Setup login urls:: +- Setup login URLs:: LOGIN_URL = '/login-form/' LOGIN_REDIRECT_URL = '/logged-in/' LOGIN_ERROR_URL = '/login-error/' - Check Django documentation at `Login url`_ and `Login redirect url`_ + Check Django documentation at `Login URL`_ and `Login redirect URL`_ - Configure authentication and association complete URL names to avoid possible clashes:: @@ -90,7 +92,7 @@ Installation SOCIAL_AUTH_COMPLETE_URL_NAME = 'namespace:complete' SOCIAL_AUTH_ASSOCIATE_URL_NAME = 'namespace:association_complete' -- Add urls entries:: +- Add URLs entries:: urlpatterns = patterns('', ... @@ -118,40 +120,37 @@ Installation SOCIAL_AUTH_EXTRA_DATA = False -- It's possible to override the used User class if needed:: +- It's possible to override the used User model if needed:: SOCIAL_AUTH_USER_MODEL = 'myapp.CustomUser' - this class must define the following fields:: + This class *must* have a custom `Model Manager`_ with a create_user method + that resembles the one on `auth.UserManager`_. + + Also, it's highly recommended that this class define the following fields:: username = CharField(...) - email = EmailField(...) - password = CharField(...) last_login = DateTimeField(blank=True) is_active = BooleanField(...) - and the methods:: - - is_authenticated() - - These are needed to ensure django-auth integration. AttributeError is raised - if any of these are missing. + and the method:: - Also the following are recommended but not enforced:: + is_authenticated(): + ... - first_name = CharField(...) - last_name = CharField(...) + These are needed to ensure a better django-auth integration, in other case + `login_required`_ won't be usable. A warning is displayed if any of these are + missing. By default `auth.User`_ is used. - By default `auth.User`_ is used. Check example application for - implementation details, but first, please take a look to `User Profiles`_, - it might solve your case. + Check example application for implementation details, but first, please take + a look to `User Profiles`_, it might be what you were looking for. ------- Signals ------- A pre_update signal is sent when user data is about to be updated with new -values from auth service provider, this apply to new users and already +values from authorization service provider, this apply to new users and already existent ones. This is useful to update custom user fields or `User Profiles`_, for example, to store user gender, location, etc. Example:: @@ -190,7 +189,7 @@ Twitter offers per application keys named "Consumer Key" and "Consumer Secret". To enable Twitter these two keys are needed. Further documentation at `Twitter development resources`_: -- Register a new app at `Twitter App Creation`_, +- Register a new application at `Twitter App Creation`_, - mark the "Yes, use Twitter for login" checkbox, and @@ -199,7 +198,7 @@ Further documentation at `Twitter development resources`_: TWITTER_CONSUMER_KEY TWITTER_CONSUMER_SECRET -- You don't need to specify the url callback +- You don't need to specify the URL callback -------- @@ -209,7 +208,7 @@ Facebook works similar to Twitter but it's simpler to setup and redirect URL is passed as a parameter when issuing an authorization. Further documentation at `Facebook development resources`_: -- Register a new app at `Facebook App Creation`_, and +- Register a new application at `Facebook App Creation`_, and - fill "App Id" and "App Secret" values in values:: @@ -262,22 +261,25 @@ Base work is copyrighted by: Copyright (C) 2007 Simon Willison Copyright (C) 2008-2010 Canonical Ltd. +.. _Model Manager: http://docs.djangoproject.com/en/dev/topics/db/managers/#managers +.. _Login URL: http://docs.djangoproject.com/en/dev/ref/settings/?from=olddocs#login-url +.. _Login redirect URL: http://docs.djangoproject.com/en/dev/ref/settings/?from=olddocs#login-redirect-url +.. _AUTHENTICATION_BACKENDS: http://docs.djangoproject.com/en/dev/ref/settings/?from=olddocs#authentication-backends +.. _auth.User: http://code.djangoproject.com/browser/django/trunk/django/contrib/auth/models.py#L186 +.. _auth.UserManager: http://code.djangoproject.com/browser/django/trunk/django/contrib/auth/models.py#L114 +.. _login_required: http://code.djangoproject.com/browser/django/trunk/django/contrib/auth/decorators.py#L39 +.. _User Profiles: http://www.djangobook.com/en/1.0/chapter12/#cn222 .. _OpenId: http://openid.net/ .. _OAuth: http://oauth.net/ .. _django-twitter-oauth: https://github.com/henriklied/django-twitter-oauth .. _django-openid-auth: https://launchpad.net/django-openid-auth .. _python-openid: http://pypi.python.org/pypi/python-openid/ .. _python-oauth: https://github.com/leah/python-oauth -.. _Login url: http://docs.djangoproject.com/en/dev/ref/settings/?from=olddocs#login-url -.. _Login redirect url: http://docs.djangoproject.com/en/dev/ref/settings/?from=olddocs#login-redirect-url .. _Twitter development resources: http://dev.twitter.com/pages/auth .. _Twitter App Creation: http://twitter.com/apps/new .. _dnsmasq: http://www.thekelleys.org.uk/dnsmasq/doc.html .. _Facebook development resources: http://developers.facebook.com/docs/authentication/ .. _Facebook App Creation: http://developers.facebook.com/setup/ -.. _AUTHENTICATION_BACKENDS: http://docs.djangoproject.com/en/dev/ref/settings/?from=olddocs#authentication-backends -.. _auth.User: http://code.djangoproject.com/browser/django/trunk/django/contrib/auth/models.py#L186 -.. _User Profiles: http://www.djangobook.com/en/1.0/chapter12/#cn222 .. _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 diff --git a/example/app/models.py b/example/app/models.py index 45ecf2435..8c28f48f6 100644 --- a/example/app/models.py +++ b/example/app/models.py @@ -1,15 +1,17 @@ # Define a custom User class to work with django-social-auth -# -# from django.db import models -# -# class CustomUser(models.Model): -# username = models.CharField(max_length=128) -# email = models.EmailField() -# password = models.CharField(max_length=128) -# last_login = models.DateTimeField(blank=True, null=True) -# first_name = models.CharField(max_length=128, blank=True) -# last_name = models.CharField(max_length=128, blank=True) -# is_active = models.BooleanField(default=True) -# -# def is_authenticated(self): -# return True +from django.db import models + + +class CustomUserManager(models.Manager): + def create_user(self, username, email): + return self.model._default_manager.create(username=username) + + +class CustomUser(models.Model): + username = models.CharField(max_length=128) + last_login = models.DateTimeField(blank=True, null=True) + + objects = CustomUserManager() + + def is_authenticated(self): + return True diff --git a/example/app/views.py b/example/app/views.py index 57cf0517b..690c06954 100644 --- a/example/app/views.py +++ b/example/app/views.py @@ -5,6 +5,8 @@ def home(request): + if request.user.is_authenticated(): + return HttpResponseRedirect('done') return HttpResponse(Template( """ @@ -44,6 +46,7 @@ def home(request): @login_required def done(request): + names = request.user.social_auth.values_list('provider', flat=True) return HttpResponse(Template( """ @@ -65,11 +68,11 @@ def done(request):

Associate new credentials:

- """).render(RequestContext(request)), + """).render(RequestContext(request, dict((name.lower(), True) + for name in names))), content_type='text/html;charset=UTF-8') diff --git a/example/settings.py b/example/settings.py index c99965eb0..efb2b2cdb 100644 --- a/example/settings.py +++ b/example/settings.py @@ -95,6 +95,8 @@ AUTHENTICATION_BACKENDS = ( 'social_auth.backends.TwitterBackend', 'social_auth.backends.FacebookBackend', + 'social_auth.backends.GoogleBackend', + 'social_auth.backends.YahooBackend', 'social_auth.backends.OpenIDBackend', 'django.contrib.auth.backends.ModelBackend', ) diff --git a/social_auth/auth.py b/social_auth/auth.py index c3ea7a977..f91d55d19 100644 --- a/social_auth/auth.py +++ b/social_auth/auth.py @@ -15,7 +15,7 @@ from .store import DjangoOpenIDStore from .backends import TwitterBackend, OrkutBackend, FacebookBackend, \ - OpenIDBackend + OpenIDBackend, GoogleBackend, YahooBackend from .conf import AX_ATTRS, SREG_ATTR, OPENID_ID_FIELD, SESSION_NAME, \ OPENID_GOOGLE_URL, OPENID_YAHOO_URL, TWITTER_SERVER, \ TWITTER_REQUEST_TOKEN_URL, TWITTER_ACCESS_TOKEN_URL, \ @@ -54,7 +54,12 @@ def uses_redirect(self): class OpenIdAuth(BaseAuth): - """OpenId process handling""" + """ + OpenId process handling + @AUTH_BACKEND Authorization backend related with this service + """ + AUTH_BACKEND = OpenIDBackend + def auth_url(self): openid_request = self.setup_request() # Construct completion URL, including page we should redirect to @@ -78,7 +83,7 @@ def auth_complete(self, *args, **kwargs): if not response: raise ValueError, 'This is an OpenID relying party endpoint' elif response.status == SUCCESS: - kwargs.update({'response': response, OpenIDBackend.name: True}) + kwargs.update({'response': response, self.AUTH_BACKEND.name: True}) return authenticate(*args, **kwargs) elif response.status == FAILURE: raise ValueError, 'OpenID authentication failed: %s' % response.message @@ -140,6 +145,8 @@ def openid_url(self): class GoogleAuth(OpenIdAuth): """Google OpenID authentication""" + AUTH_BACKEND = GoogleBackend + def openid_url(self): """Return Google OpenID service url""" return OPENID_GOOGLE_URL @@ -147,6 +154,8 @@ def openid_url(self): class YahooAuth(OpenIdAuth): """Yahoo OpenID authentication""" + AUTH_BACKEND = YahooBackend + def openid_url(self): """Return Yahoo OpenID service url""" return OPENID_YAHOO_URL diff --git a/social_auth/backends.py b/social_auth/backends.py index c921aaf15..f4ae7dde6 100644 --- a/social_auth/backends.py +++ b/social_auth/backends.py @@ -7,12 +7,13 @@ from django.conf import settings from django.contrib.auth.backends import ModelBackend -from django.contrib.auth.models import UNUSABLE_PASSWORD from .models import UserSocialAuth from .conf import OLD_AX_ATTRS, AX_SCHEMA_ATTRS from .signals import pre_update +USERNAME = 'username' + # get User class, could not be auth.User User = UserSocialAuth._meta.get_field('user').rel.to @@ -38,20 +39,28 @@ def authenticate(self, *args, **kwargs): response = kwargs.get('response') details = self.get_user_details(response) uid = self.get_user_id(details, response) + new_user = False try: - auth_user = UserSocialAuth.objects.select_related('user')\ - .get(provider=self.name, - uid=uid) + social_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(details=details, *args, **kwargs) + user = kwargs.get('user') + if user is None: # new user + if not getattr(settings, 'SOCIAL_AUTH_CREATE_USERS', True): + return None + username = self.username(details) + email = details.get('email') + user = User.objects.create_user(username=username, email=email) + new_user = True + social_user = self.associate_auth(user, uid, response, details) else: - user = auth_user.user - self.update_user_details(user, response, details) + user = social_user.user + + self.update_user_details(user, response, details, new_user=new_user) return user - def get_username(self, details): + def 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 """ @@ -61,8 +70,8 @@ def get_random_username(): if getattr(settings, 'SOCIAL_AUTH_FORCE_RANDOM_USERNAME', False): username = get_random_username() - elif 'username' in details: - username = details['username'] + elif USERNAME in details: + username = details[USERNAME] elif hasattr(settings, 'SOCIAL_AUTH_DEFAULT_USERNAME'): username = settings.SOCIAL_AUTH_DEFAULT_USERNAME if callable(username): @@ -81,54 +90,27 @@ def get_random_username(): break return username - 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, response, 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_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_social = UserSocialAuth.objects.create(user=user, uid=uid, - provider=self.name, - extra_data=extra_data) - else: - if user_social.user != user: - raise ValueError, 'Identity already claimed' - return user_social + def associate_auth(self, user, uid, response, details): + """Associate a Social Auth with an user account.""" + extra_data = '' if not getattr(settings, 'SOCIAL_AUTH_EXTRA_DATA', + False) \ + else self.extra_data(user, uid, response, details) + return UserSocialAuth.objects.create(user=user, uid=uid, + provider=self.name, + extra_data=extra_data) def extra_data(self, user, uid, response, details): """Return default blank user extra data""" return '' - def update_user_details(self, user, response, details): - """Update user details with new (maybe) data""" + def update_user_details(self, user, response, details, new_user=False): + """Update user details with (maybe) new data. Username is not + changed if associating a new credential.""" changed = False for name, value in details.iteritems(): + # not update username if user already exists + if not new_user and name == USERNAME: + continue if value and value != getattr(user, name, value): setattr(user, name, value) changed = True @@ -137,7 +119,7 @@ def update_user_details(self, user, response, details): # user instance (created or retrieved from database), service # response and processed details, signal handlers must return # True or False to signal that something has changed - updated = filter(bool, pre_update.send(sender=self, user=user, + updated = filter(None, pre_update.send(sender=self, user=user, response=response, details=details)) if changed or len(updated) > 0: @@ -149,8 +131,8 @@ def get_user_id(self, details, response): def get_user_details(self, response): """Must return user details in a know internal struct: - {'email': , - 'username': , + {USERNAME: , + 'email': , 'fullname': , 'first_name': , 'last_name': } @@ -182,8 +164,8 @@ class TwitterBackend(OAuthBackend): def get_user_details(self, response): """Return user details from Twitter account""" - return {'email': '', # not supplied - 'username': response['screen_name'], + return {USERNAME: response['screen_name'], + 'email': '', # not supplied 'fullname': response['name'], 'first_name': response['name'], 'last_name': ''} @@ -195,8 +177,8 @@ class OrkutBackend(OAuthBackend): def get_user_details(self, response): """Return user details from Orkut account""" - return {'email': response['emails'][0]['value'], - 'username': response['displayName'], + return {USERNAME: response['displayName'], + 'email': response['emails'][0]['value'], 'fullname': response['displayName'], 'firstname': response['name']['givenName'], 'lastname': response['name']['familyName']} @@ -208,8 +190,8 @@ class FacebookBackend(OAuthBackend): def get_user_details(self, response): """Return user details from Facebook account""" - return {'email': response.get('email', ''), - 'username': response['name'], + return {USERNAME: response['name'], + 'email': response.get('email', ''), 'fullname': response['name'], 'first_name': response.get('first_name', ''), 'last_name': response.get('last_name', '')} @@ -225,11 +207,8 @@ def get_user_id(self, details, response): def get_user_details(self, response): """Return user details from an OpenID request""" - values = {'email': '', - 'username': '', - 'fullname': '', - 'first_name': '', - 'last_name': ''} + values = {USERNAME: '', 'email': '', 'fullname': '', + 'first_name': '', 'last_name': ''} resp = sreg.SRegResponse.fromSuccessResponse(response) if resp: @@ -254,9 +233,17 @@ def get_user_details(self, response): except ValueError: last_name = fullname - values.update({'fullname': fullname, - 'first_name': first_name, + values.update({'fullname': fullname, 'first_name': first_name, 'last_name': last_name, - 'username': values.get('username') or \ + USERNAME: values.get(USERNAME) or \ (first_name.title() + last_name.title())}) return values + + +class GoogleBackend(OpenIDBackend): + """Google OpenID authentication backend""" + name = 'google' + +class YahooBackend(OpenIDBackend): + """Yahoo OpenID authentication backend""" + name = 'yahoo' diff --git a/social_auth/models.py b/social_auth/models.py index 8ef3a96f5..2008dafbf 100644 --- a/social_auth/models.py +++ b/social_auth/models.py @@ -1,30 +1,33 @@ """Social auth models""" +import warnings + from django.db import models from django.conf import settings # If User class is overrided, it must provide the following fields, # or it won't be playing nicely with auth module: # -# username = CharField() -# email = EmailField() -# password = CharField() +# username = CharField() +# last_login = DateTimeField() # is_active = BooleanField() # # and methods: # # def is_authenticated(): # ... -MANDATORY_FIELDS = ('username', 'email', 'password', 'last_login') -MANDATORY_METHODS = ('is_authenticated',) +RECOMMENDED_FIELDS = ('username', 'last_login', 'is_active') +RECOMMENDED_METHODS = ('is_authenticated',) -try: # try to import User model override and validate needed fields +if getattr(settings, 'SOCIAL_AUTH_USER_MODEL', None): User = models.get_model(*settings.SOCIAL_AUTH_USER_MODEL.split('.')) - if not all(User._meta.get_field(name) for name in MANDATORY_FIELDS): - raise AttributeError, 'Some mandatory field missing' - if not all(callable(getattr(User, name, None)) - for name in MANDATORY_METHODS): - raise AttributeError, 'Some mandatory methods missing' -except AttributeError: # fail silently on missing setting + missing = list(set(RECOMMENDED_FIELDS) - + set(User._meta.get_all_field_names())) + \ + [name for name in RECOMMENDED_METHODS + if not callable(getattr(User, name, None))] + if missing: + warnings.warn('Missing recommended attributes or methods '\ + 'in custom User model: "%s"' % ', '.join(missing)) +else: from django.contrib.auth.models import User @@ -35,6 +38,10 @@ class UserSocialAuth(models.Model): uid = models.CharField(max_length=255) extra_data = models.TextField(default='', blank=True) + def __unicode__(self): + """Return associated user unicode representation""" + return unicode(self.user) + class Meta: """Meta data""" unique_together = ('provider', 'uid') diff --git a/social_auth/views.py b/social_auth/views.py index bb476891b..c0e8500d1 100644 --- a/social_auth/views.py +++ b/social_auth/views.py @@ -34,7 +34,7 @@ def complete(request, backend): return HttpResponseServerError('Incorrect authentication service') backend = BACKENDS[backend](request, request.path) user = backend.auth_complete() - if user and user.is_active: + if user and getattr(user, 'is_active', True): login(request, user) url = request.session.pop(REDIRECT_FIELD_NAME, '') or \ getattr(settings, 'LOGIN_REDIRECT_URL', '') @@ -58,7 +58,7 @@ def associate_complete(request, backend): if backend not in BACKENDS: return HttpResponseServerError('Incorrect authentication service') backend = BACKENDS[backend](request, request.path) - user = backend.auth_complete(user=request.user) + backend.auth_complete(user=request.user) url = request.session.pop(REDIRECT_FIELD_NAME, '') or \ getattr(settings, 'LOGIN_REDIRECT_URL', '') return HttpResponseRedirect(url)