From 8a4afe746c1ed2d7159deb7f2ed340c5d1f68473 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aleksi=20H=C3=A4kli?= Date: Sat, 2 Dec 2017 16:00:16 +0200 Subject: [PATCH] Implement connect social accounts functionality Add serializers and views for connecting accounts with minimal specialization of the existing django-rest-auth interfaces. Also add viewset which enables listing social account infmration via the REST API for social authentication driven client applications. Resolves #347 in GitHub. --- docs/installation.rst | 20 ++++- rest_auth/registration/serializers.py | 45 +++++++++- rest_auth/registration/views.py | 120 ++++++++++++++++++++------ rest_auth/social_serializers.py | 113 ++++++++++++------------ 4 files changed, 212 insertions(+), 86 deletions(-) diff --git a/docs/installation.rst b/docs/installation.rst index fd09cd7..b694ccc 100644 --- a/docs/installation.rst +++ b/docs/installation.rst @@ -111,11 +111,15 @@ Facebook .. code-block:: python from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter - from rest_auth.registration.views import SocialLoginView + from rest_auth.registration.views import SocialLoginView, SocialConnectView class FacebookLogin(SocialLoginView): adapter_class = FacebookOAuth2Adapter + # Add a connect view if you want to allow connecting existing accounts + class FacebookConnect(SocialConnectView): + adapter_class = FacebookOAuth2Adapter + 4. Create url for FacebookLogin view: .. code-block:: python @@ -123,6 +127,7 @@ Facebook urlpatterns += [ ..., url(r'^rest-auth/facebook/$', FacebookLogin.as_view(), name='fb_login') + url(r'^rest-auth/facebook/connect/$', FacebookConnect.as_view(), name='fb_connect') ] @@ -136,13 +141,19 @@ If you are using Twitter for your social authentication, it is a bit different s .. code-block:: python from allauth.socialaccount.providers.twitter.views import TwitterOAuthAdapter - from rest_auth.views import LoginView - from rest_auth.social_serializers import TwitterLoginSerializer + from rest_auth.registration.views import SocialLoginView + from rest_auth.social_serializers import TwitterLoginSerializer, TwitterConnectSerializer - class TwitterLogin(LoginView): + class TwitterLogin(SocialLoginView): serializer_class = TwitterLoginSerializer adapter_class = TwitterOAuthAdapter + # Add a connect view if you want to allow connecting existing accounts + class TwitterConnect(SocialConnectView): + serializer_class = TwitterConnectSerializer + adapter_class = TwitterOAuthAdapter + + 4. Create url for TwitterLogin view: .. code-block:: python @@ -150,6 +161,7 @@ If you are using Twitter for your social authentication, it is a bit different s urlpatterns += [ ..., url(r'^rest-auth/twitter/$', TwitterLogin.as_view(), name='twitter_login') + url(r'^rest-auth/twitter/connect/$', TwitterConnect.as_view(), name='twitter_login') ] .. note:: Starting from v0.21.0, django-allauth has dropped support for context processors. Check out http://django-allauth.readthedocs.org/en/latest/changelog.html#from-0-21-0 for more details. diff --git a/rest_auth/registration/serializers.py b/rest_auth/registration/serializers.py index b2e9d51..160b4d6 100644 --- a/rest_auth/registration/serializers.py +++ b/rest_auth/registration/serializers.py @@ -14,10 +14,6 @@ except ImportError: from rest_framework import serializers from requests.exceptions import HTTPError -# Import is needed only if we are using social login, in which -# case the allauth.socialaccount will be declared -if 'allauth.socialaccount' in settings.INSTALLED_APPS: - from allauth.socialaccount.helpers import complete_social_login class SocialLoginSerializer(serializers.Serializer): @@ -186,3 +182,44 @@ class RegisterSerializer(serializers.Serializer): class VerifyEmailSerializer(serializers.Serializer): key = serializers.CharField() + + +# Import is needed only if we are using social login, in which +# case the allauth.socialaccount will be declared +if 'allauth.socialaccount' in settings.INSTALLED_APPS: + from allauth.socialaccount.helpers import complete_social_login + from allauth.socialaccount.models import SocialAccount + from allauth.socialaccount.providers.base import AuthProcess + + class SocialAccountSerializer(serializers.ModelSerializer): + """ + serialize allauth SocialAccounts for use with a REST API + """ + class Meta: + model = SocialAccount + fields = ( + 'id', + 'provider', + 'uid', + 'last_login', + 'date_joined', + 'extra_data', + ) + + + class SocialConnectMixin(object): + def get_social_login(self, *args, **kwargs): + """ + set the social login process state to connect rather than login + + Refer to the implementation of get_social_login in base class and to the + allauth.socialaccount.helpers module complete_social_login function. + """ + + social_login = super(SocialConnectMixin, self).get_social_login(*args, **kwargs) + social_login.state['process'] = AuthProcess.CONNECT + return social_login + + + class SocialConnectSerializer(SocialConnectMixin, SocialLoginSerializer): + pass diff --git a/rest_auth/registration/views.py b/rest_auth/registration/views.py index d6638b6..9343e72 100644 --- a/rest_auth/registration/views.py +++ b/rest_auth/registration/views.py @@ -5,11 +5,13 @@ from django.views.decorators.debug import sensitive_post_parameters from rest_framework.views import APIView from rest_framework.response import Response -from rest_framework.permissions import AllowAny +from rest_framework.permissions import (AllowAny, + IsAuthenticated) +from rest_framework.decorators import detail_route +from rest_framework.viewsets import GenericViewSet from rest_framework.generics import CreateAPIView from rest_framework import status -from allauth.account.adapter import get_adapter from allauth.account.views import ConfirmEmailView from allauth.account.utils import complete_signup from allauth.account import app_settings as allauth_settings @@ -19,7 +21,8 @@ from rest_auth.app_settings import (TokenSerializer, create_token) from rest_auth.models import TokenModel from rest_auth.registration.serializers import (SocialLoginSerializer, - VerifyEmailSerializer) + VerifyEmailSerializer, + SocialConnectSerializer) from rest_auth.utils import jwt_encode from rest_auth.views import LoginView from .app_settings import RegisterSerializer, register_permission_classes @@ -91,31 +94,98 @@ class VerifyEmailView(APIView, ConfirmEmailView): return Response({'detail': _('ok')}, status=status.HTTP_200_OK) -class SocialLoginView(LoginView): - """ - class used for social authentications - example usage for facebook with access_token - ------------- - from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter +if 'allauth.socialaccount' in settings.INSTALLED_APPS: + from allauth.socialaccount import signals + from allauth.socialaccount.models import SocialAccount + from allauth.socialaccount.adapter import get_adapter - class FacebookLogin(SocialLoginView): - adapter_class = FacebookOAuth2Adapter - ------------- + from rest_auth.registration.serializers import SocialAccountSerializer - example usage for facebook with code - ------------- - from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter - from allauth.socialaccount.providers.oauth2.client import OAuth2Client + class SocialLoginView(LoginView): + """ + class used for social authentications + example usage for facebook with access_token + ------------- + from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter - class FacebookLogin(SocialLoginView): - adapter_class = FacebookOAuth2Adapter - client_class = OAuth2Client - callback_url = 'localhost:8000' - ------------- - """ + class FacebookLogin(SocialLoginView): + adapter_class = FacebookOAuth2Adapter + ------------- - serializer_class = SocialLoginSerializer + example usage for facebook with code - def process_login(self): - get_adapter(self.request).login(self.request, self.user) + ------------- + from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter + from allauth.socialaccount.providers.oauth2.client import OAuth2Client + + class FacebookLogin(SocialLoginView): + adapter_class = FacebookOAuth2Adapter + client_class = OAuth2Client + callback_url = 'localhost:8000' + ------------- + """ + + serializer_class = SocialLoginSerializer + + def process_login(self): + get_adapter(self.request).login(self.request, self.user) + + + class SocialConnectView(SocialLoginView): + """ + class used for social account linking + + example usage for facebook with access_token + ------------- + from allauth.socialaccount.providers.facebook.views import FacebookOAuth2Adapter + + class FacebookConnect(SocialConnectView): + adapter_class = FacebookOAuth2Adapter + ------------- + """ + + serializer_class = SocialConnectSerializer + permission_classes = (IsAuthenticated,) + + + class SocialAccountViewSet(GenericViewSet): + """ + allauth SocialAccount REST API read and disconnect views for logged in users + + Refer to the django-allauth package implementation of the models and + account handling logic for more details, this viewset emulates the allauth web UI. + """ + + serializer_class = SocialAccountSerializer + permission_classes = (IsAuthenticated,) + queryset = SocialAccount.objects.none() + + def get_queryset(self): + return SocialAccount.objects.filter(user=self.request.user) + + def list(self, request): + """ + list SocialAccounts for the currently logged in user + """ + + return Response(self.get_serializer(self.get_queryset(), many=True).data) + + @detail_route(methods=['POST']) + def disconnect(self, request, pk): + """ + disconnect SocialAccount from remote service for the currently logged in user + """ + + accounts = self.get_queryset() + account = accounts.get(pk=pk) + get_adapter(self.request).validate_disconnect(account, accounts) + + account.delete() + signals.social_account_removed.send( + sender=SocialAccount, + request=self.request, + socialaccount=account + ) + + return Response(self.get_serializer(account).data) diff --git a/rest_auth/social_serializers.py b/rest_auth/social_serializers.py index 665b98d..6161d1a 100644 --- a/rest_auth/social_serializers.py +++ b/rest_auth/social_serializers.py @@ -1,6 +1,7 @@ from django.conf import settings from django.http import HttpRequest from rest_framework import serializers + # Import is needed only if we are using social login, in which # case the allauth.socialaccount will be declared if 'allauth.socialaccount' in settings.INSTALLED_APPS: @@ -8,68 +9,74 @@ if 'allauth.socialaccount' in settings.INSTALLED_APPS: from allauth.socialaccount.models import SocialToken from allauth.socialaccount.providers.oauth.client import OAuthError + from rest_auth.registration.serializers import SocialConnectMixin -class TwitterLoginSerializer(serializers.Serializer): - access_token = serializers.CharField() - token_secret = serializers.CharField() - def _get_request(self): - request = self.context.get('request') - if not isinstance(request, HttpRequest): - request = request._request - return request + class TwitterLoginSerializer(serializers.Serializer): + access_token = serializers.CharField() + token_secret = serializers.CharField() - def get_social_login(self, adapter, app, token, response): - """ - :param adapter: allauth.socialaccount Adapter subclass. - Usually OAuthAdapter or Auth2Adapter - :param app: `allauth.socialaccount.SocialApp` instance - :param token: `allauth.socialaccount.SocialToken` instance - :param response: Provider's response for OAuth1. Not used in the - :returns: A populated instance of the - `allauth.socialaccount.SocialLoginView` instance - """ - request = self._get_request() - social_login = adapter.complete_login(request, app, token, - response=response) - social_login.token = token - return social_login + def _get_request(self): + request = self.context.get('request') + if not isinstance(request, HttpRequest): + request = request._request + return request - def validate(self, attrs): - view = self.context.get('view') - request = self._get_request() + def get_social_login(self, adapter, app, token, response): + """ + :param adapter: allauth.socialaccount Adapter subclass. + Usually OAuthAdapter or Auth2Adapter + :param app: `allauth.socialaccount.SocialApp` instance + :param token: `allauth.socialaccount.SocialToken` instance + :param response: Provider's response for OAuth1. Not used in the + :returns: A populated instance of the + `allauth.socialaccount.SocialLoginView` instance + """ + request = self._get_request() + social_login = adapter.complete_login(request, app, token, + response=response) + social_login.token = token + return social_login - if not view: - raise serializers.ValidationError( - "View is not defined, pass it as a context variable" - ) + def validate(self, attrs): + view = self.context.get('view') + request = self._get_request() - adapter_class = getattr(view, 'adapter_class', None) - if not adapter_class: - raise serializers.ValidationError("Define adapter_class in view") + if not view: + raise serializers.ValidationError( + "View is not defined, pass it as a context variable" + ) - adapter = adapter_class(request) - app = adapter.get_provider().get_app(request) + adapter_class = getattr(view, 'adapter_class', None) + if not adapter_class: + raise serializers.ValidationError("Define adapter_class in view") - access_token = attrs.get('access_token') - token_secret = attrs.get('token_secret') + adapter = adapter_class(request) + app = adapter.get_provider().get_app(request) - request.session['oauth_api.twitter.com_access_token'] = { - 'oauth_token': access_token, - 'oauth_token_secret': token_secret, - } - token = SocialToken(token=access_token, token_secret=token_secret) - token.app = app + access_token = attrs.get('access_token') + token_secret = attrs.get('token_secret') - try: - login = self.get_social_login(adapter, app, token, access_token) - complete_social_login(request, login) - except OAuthError as e: - raise serializers.ValidationError(str(e)) + request.session['oauth_api.twitter.com_access_token'] = { + 'oauth_token': access_token, + 'oauth_token_secret': token_secret, + } + token = SocialToken(token=access_token, token_secret=token_secret) + token.app = app - if not login.is_existing: - login.lookup() - login.save(request, connect=True) - attrs['user'] = login.account.user + try: + login = self.get_social_login(adapter, app, token, access_token) + complete_social_login(request, login) + except OAuthError as e: + raise serializers.ValidationError(str(e)) - return attrs + if not login.is_existing: + login.lookup() + login.save(request, connect=True) + attrs['user'] = login.account.user + + return attrs + + + class TwitterConnectSerializer(SocialConnectMixin, TwitterLoginSerializer): + pass