Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with
or
.
Download ZIP
Browse files

Added support for Stack Exchange

  • Loading branch information...
commit 4644fc3f53acdbc7c8abaced849a40595f12d432 1 parent 93eca00
@pennersr authored
View
4 ChangeLog
@@ -1,3 +1,7 @@
+2012-12-22 Raymond Penners <raymond.penners@intenct.nl>
+
+ * socialaccount: Added support for Stack Exchange.
+
2012-12-14 Raymond Penners <raymond.penners@intenct.nl>
* socialaccount: Added `get_social_accounts` template tag.
View
31 README.rst
@@ -91,6 +91,8 @@ Supported Providers
- SoundCloud (OAuth2)
+- Stack Exchange (OAuth2)
+
- Twitter
Note: OAuth/OAuth2 support is built using a common code base, making it easy to add support for additional OAuth/OAuth2 providers. More will follow soon...
@@ -269,6 +271,16 @@ SOCIALACCOUNT_PROVIDERS (= dict)
Upgrading
---------
+From 0.8.3
+**********
+
+- `requests` is now a dependency (dropped `httplib2`).
+
+- Added a new column `SocialApp.client_id`. The value of `key` needs
+ to be moved to the new `client_id` column. The `key` column is
+ required for Stack Exchange. Migrations are in place to handle all
+ of this automatically.
+
From 0.8.2
**********
@@ -527,6 +539,25 @@ SoundCloud allows you to choose between OAuth1 and OAuth2. Choose the
latter.
+Stack Exchange
+--------------
+
+Register your OAuth2 over at
+`http://stackapps.com/apps/oauth/register`. Do not enable "Client
+Side Flow". For local development you can simply use "localhost" for
+the OAuth domain.
+
+As for all providers, provider specific data is stored in
+`SocialAccount.extra_data`. For Stack Exchange we need to choose what
+data to store there by choosing the Stack Exchange site (e.g. Stack
+Overflow, or Server Fault). This can be controlled by means of the
+`SITE` setting::
+
+ SOCIALACCOUNT_PROVIDERS = \
+ { 'stackexchange':
+ { 'SITE': 'stackoverflow' } }
+
+
Signals
=======
View
94 allauth/socialaccount/migrations/0007_auto__add_field_socialapp_client_id.py
@@ -0,0 +1,94 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import SchemaMigration
+from django.db import models
+
+class Migration(SchemaMigration):
+
+ def forwards(self, orm):
+
+ # Adding field 'SocialApp.client_id'
+ db.add_column('socialaccount_socialapp', 'client_id', self.gf('django.db.models.fields.CharField')(default='', max_length=100), keep_default=False)
+
+
+ def backwards(self, orm):
+
+ # Deleting field 'SocialApp.client_id'
+ db.delete_column('socialaccount_socialapp', 'client_id')
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 22, 12, 51, 3, 966915)'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 22, 12, 51, 3, 966743)'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'sites.site': {
+ 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"},
+ 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'socialaccount.socialaccount': {
+ 'Meta': {'unique_together': "(('provider', 'uid'),)", 'object_name': 'SocialAccount'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'extra_data': ('allauth.socialaccount.fields.JSONField', [], {'default': "'{}'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+ 'provider': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
+ 'uid': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'socialaccount.socialapp': {
+ 'Meta': {'object_name': 'SocialApp'},
+ 'client_id': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'provider': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'sites': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['sites.Site']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'socialaccount.socialtoken': {
+ 'Meta': {'unique_together': "(('app', 'account'),)", 'object_name': 'SocialToken'},
+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['socialaccount.SocialAccount']"}),
+ 'app': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['socialaccount.SocialApp']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'token': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+ 'token_secret': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+ }
+ }
+
+ complete_apps = ['socialaccount']
View
93 allauth/socialaccount/migrations/0008_client_id.py
@@ -0,0 +1,93 @@
+# encoding: utf-8
+import datetime
+from south.db import db
+from south.v2 import DataMigration
+from django.db import models
+
+class Migration(DataMigration):
+
+ def forwards(self, orm):
+ "Write your forwards methods here."
+ for app in orm.SocialApp.objects.all():
+ app.client_id = app.key
+ app.key = ''
+ app.save()
+
+ def backwards(self, orm):
+ "Write your backwards methods here."
+
+
+ models = {
+ 'auth.group': {
+ 'Meta': {'object_name': 'Group'},
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}),
+ 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'auth.permission': {
+ 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'},
+ 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'auth.user': {
+ 'Meta': {'object_name': 'User'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 22, 12, 51, 18, 10544)'}),
+ 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}),
+ 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}),
+ 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime(2012, 12, 22, 12, 51, 18, 10426)'}),
+ 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}),
+ 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}),
+ 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}),
+ 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'})
+ },
+ 'contenttypes.contenttype': {
+ 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"},
+ 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'})
+ },
+ 'sites.site': {
+ 'Meta': {'ordering': "('domain',)", 'object_name': 'Site', 'db_table': "'django_site'"},
+ 'domain': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'})
+ },
+ 'socialaccount.socialaccount': {
+ 'Meta': {'unique_together': "(('provider', 'uid'),)", 'object_name': 'SocialAccount'},
+ 'date_joined': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}),
+ 'extra_data': ('allauth.socialaccount.fields.JSONField', [], {'default': "'{}'"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'last_login': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}),
+ 'provider': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
+ 'uid': ('django.db.models.fields.CharField', [], {'max_length': '255'}),
+ 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"})
+ },
+ 'socialaccount.socialapp': {
+ 'Meta': {'object_name': 'SocialApp'},
+ 'client_id': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'key': ('django.db.models.fields.CharField', [], {'max_length': '100', 'blank': 'True'}),
+ 'name': ('django.db.models.fields.CharField', [], {'max_length': '40'}),
+ 'provider': ('django.db.models.fields.CharField', [], {'max_length': '30'}),
+ 'secret': ('django.db.models.fields.CharField', [], {'max_length': '100'}),
+ 'sites': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['sites.Site']", 'symmetrical': 'False', 'blank': 'True'})
+ },
+ 'socialaccount.socialtoken': {
+ 'Meta': {'unique_together': "(('app', 'account'),)", 'object_name': 'SocialToken'},
+ 'account': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['socialaccount.SocialAccount']"}),
+ 'app': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['socialaccount.SocialApp']"}),
+ 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}),
+ 'token': ('django.db.models.fields.CharField', [], {'max_length': '200'}),
+ 'token_secret': ('django.db.models.fields.CharField', [], {'max_length': '200', 'blank': 'True'})
+ }
+ }
+
+ complete_apps = ['socialaccount']
View
8 allauth/socialaccount/models.py
@@ -24,10 +24,14 @@ class SocialApp(models.Model):
provider = models.CharField(max_length=30,
choices=providers.registry.as_choices())
name = models.CharField(max_length=40)
+ client_id = models.CharField(max_length=100,
+ help_text='App ID, or consumer key')
key = models.CharField(max_length=100,
- help_text='App ID, or consumer key')
+ blank=True,
+ help_text='Key (Stack Exchange only)')
secret = models.CharField(max_length=100,
- help_text='API secret, or consumer secret')
+ help_text='API secret, client secret, or'
+ ' consumer secret')
# Most apps can be used across multiple domains, therefore we use
# a ManyToManyField. Note that Facebook requires an app per domain
# (unless the domains share a common base name).
View
2  allauth/socialaccount/providers/linkedin/views.py
@@ -51,7 +51,7 @@ class LinkedInOAuthAdapter(OAuthAdapter):
authorize_url = 'https://www.linkedin.com/uas/oauth/authenticate'
def complete_login(self, request, app, token):
- client = LinkedInAPI(request, app.key, app.secret,
+ client = LinkedInAPI(request, app.client_id, app.secret,
self.request_token_url)
extra_data = client.get_user_info()
uid = extra_data['id']
View
2  allauth/socialaccount/providers/oauth/views.py
@@ -37,7 +37,7 @@ def _get_client(self, request, callback_url):
parameters = {}
if scope:
parameters['scope'] = scope
- client = OAuthClient(request, app.key, app.secret,
+ client = OAuthClient(request, app.client_id, app.secret,
self.adapter.request_token_url,
self.adapter.access_token_url,
self.adapter.authorize_url,
View
2  allauth/socialaccount/providers/oauth2/views.py
@@ -33,7 +33,7 @@ def view(request, *args, **kwargs):
def get_client(self, request, app):
callback_url = reverse(self.adapter.provider_id + "_callback")
callback_url = request.build_absolute_uri(callback_url)
- client = OAuth2Client(self.request, app.key, app.secret,
+ client = OAuth2Client(self.request, app.client_id, app.secret,
self.adapter.authorize_url,
self.adapter.access_token_url,
callback_url,
View
0  allauth/socialaccount/providers/stackexchange/__init__.py
No changes.
View
1  allauth/socialaccount/providers/stackexchange/models.py
@@ -0,0 +1 @@
+# Create your models here.
View
28 allauth/socialaccount/providers/stackexchange/provider.py
@@ -0,0 +1,28 @@
+from allauth.socialaccount import providers
+from allauth.socialaccount.providers.base import ProviderAccount
+from allauth.socialaccount.providers.oauth2.provider import OAuth2Provider
+
+
+class StackExchangeAccount(ProviderAccount):
+ def get_profile_url(self):
+ return self.account.extra_data.get('html_url')
+
+ def get_avatar_url(self):
+ return self.account.extra_data.get('avatar_url')
+
+ def __unicode__(self):
+ dflt = super(StackExchangeAccount, self).__unicode__()
+ return self.account.extra_data.get('name', dflt)
+
+
+class StackExchangeProvider(OAuth2Provider):
+ id = 'stackexchange'
+ name = 'Stack Exchange'
+ package = 'allauth.socialaccount.providers.stackexchange'
+ account_class = StackExchangeAccount
+
+ def get_site(self):
+ settings = self.get_settings()
+ return settings.get('SITE', 'stackoverflow')
+
+providers.registry.register(StackExchangeProvider)
View
5 allauth/socialaccount/providers/stackexchange/urls.py
@@ -0,0 +1,5 @@
+from allauth.socialaccount.providers.oauth2.urls import default_urlpatterns
+from provider import StackExchangeProvider
+
+urlpatterns = default_urlpatterns(StackExchangeProvider)
+
View
42 allauth/socialaccount/providers/stackexchange/views.py
@@ -0,0 +1,42 @@
+import requests
+
+from allauth.socialaccount.providers.oauth2.views import (OAuth2Adapter,
+ OAuth2LoginView,
+ OAuth2CallbackView)
+from allauth.socialaccount.models import SocialAccount, SocialLogin
+from allauth.socialaccount.providers import registry
+from allauth.utils import get_user_model
+
+from provider import StackExchangeProvider
+
+User = get_user_model()
+
+class StackExchangeOAuth2Adapter(OAuth2Adapter):
+ provider_id = StackExchangeProvider.id
+ access_token_url = 'https://stackexchange.com/oauth/access_token'
+ authorize_url = 'https://stackexchange.com/oauth'
+ profile_url = 'https://api.stackexchange.com/2.1/me'
+
+ def complete_login(self, request, app, token):
+ provider = registry.by_id(app.provider)
+ site = provider.get_site()
+ resp = requests.get(self.profile_url,
+ params={ 'access_token': token.token,
+ 'key': app.key,
+ 'site': site })
+ extra_data = resp.json()['items'][0]
+ # `user_id` varies if you use the same account for
+ # e.g. StackOverflow and ServerFault. Therefore, we pick
+ # `account_id`.
+ uid = str(extra_data['account_id'])
+ user = User(username=extra_data.get('display_name', ''))
+ account = SocialAccount(user=user,
+ uid=uid,
+ extra_data=extra_data,
+ provider=self.provider_id)
+ return SocialLogin(account)
+
+
+oauth2_login = OAuth2LoginView.adapter_view(StackExchangeOAuth2Adapter)
+oauth2_callback = OAuth2CallbackView.adapter_view(StackExchangeOAuth2Adapter)
+
View
2  allauth/socialaccount/providers/twitter/views.py
@@ -31,7 +31,7 @@ class TwitterOAuthAdapter(OAuthAdapter):
authorize_url = 'https://api.twitter.com/oauth/authenticate'
def complete_login(self, request, app, token):
- client = TwitterAPI(request, app.key, app.secret,
+ client = TwitterAPI(request, app.client_id, app.secret,
self.request_token_url)
extra_data = client.get_user_info()
uid = extra_data['id']
View
7 allauth/socialaccount/tests.py
@@ -23,8 +23,11 @@
"link": "https://plus.google.com/108204268033311374519",
"given_name": "Raymond", "id": "108204268033311374519",
"verified_email": true}
-""")
-}
+"""),
+ 'stackexchange': MockedResponse(200, """
+{"has_more": false, "items": [{"is_employee": false, "last_access_date": 1356200390, "display_name": "pennersr", "account_id": 291652, "badge_counts": {"bronze": 2, "silver": 2, "gold": 0}, "last_modified_date": 1356199552, "profile_image": "http://www.gravatar.com/avatar/053d648486d567d3143d6bad8df8cfeb?d=identicon&r=PG", "user_type": "registered", "creation_date": 1296223711, "reputation_change_quarter": 148, "reputation_change_year": 378, "reputation": 504, "link": "http://stackoverflow.com/users/593944/pennersr", "reputation_change_week": 0, "user_id": 593944, "reputation_change_month": 10, "reputation_change_day": 0}], "quota_max": 10000, "quota_remaining": 9999}
+""") }
+
def create_oauth2_tests(provider):
def setUp(self):

2 comments on commit 4644fc3

@ksze

It seems that your implementation would suffer from the same problem as I faced when I tried to add StackExchange support. By now you must realize that you are really authenticating against the bare StackExchange account, but you cannot pull any useful information from this bare account - you need a profile from one of the sites in the StackExchange network. The problem is that the user does not necessarily have a StackOverflow profile (or any profile at all in the whole StackExchange network). Consequently, you may complete the OAuth 2.0, but ultimately fail to get a display_name or profile URL because there is no StackOverflow profile.

@pennersr
Owner

Well, there are two reasons for using authentication via a 3rd party such as Stack Exchange. Primary reason for the user is not to be bothered by having to setup yet another password. Secondary reason is exchange of profile information.

You're right in that no profile information is available if you query for serverfault.com profile information for a user who only has a stackoverflow profile. Still, the primary reason is addressed. I would expect sites offering a stack exchange login to explicitly label it, e.g. "Sign in with StackOverflow", not "Sign in with StackExchange". Then, under normal circumstances, users won't login via the stackexchange provider if they do not have a profile.

In principle, you can query any Stack Exchange site using the access token retrieved. So you could even iterate over al sites, collect all profile information, and store all profiles in the SocialAccount model. I feel this is a bit overkill though...

PS: code is still a bit rough (StackExchangeAccount needs work, and it probably doesn't deal gracefully with missing profile info).

Please sign in to comment.
Something went wrong with that request. Please try again.