diff --git a/.env.sample b/.env.sample index 9fb8020f..89ed0995 100644 --- a/.env.sample +++ b/.env.sample @@ -10,3 +10,10 @@ export PASSWORD_RESET_URL="127.0.0.1:8000/api" export DEFAULT_FROM_EMAIL="senders email" export SECRET_KEY="enter your secret key" export EMAIL_VERIFICATION_URL="http://127.0.0.1:8000/" + +export FACEBOOK_APP_ID="your_facebook_app_id" +export FACEBOOK_APP_SECRET="your_facebook_app_secret" +export OAUTH2_KEY="your_google_app_secret" +export OAUTH2_SECRET="your_google_secret" +export TWITTER_KEY="your_twitter_key" +export TWITTER_SECRET="your_twitter_secret" diff --git a/.gitignore b/.gitignore index fa709082..66ac3f66 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,7 @@ var/ *.egg .idea/ + # PyInstaller # Usually these files are written by a python script from a template # before PyInstaller builds the exe, so as to inject date/other infos into it. diff --git a/.travis.yml b/.travis.yml index fcb1e6c6..1950b0e1 100644 --- a/.travis.yml +++ b/.travis.yml @@ -12,11 +12,11 @@ services: - postgresql addons: - postgresql: "9.6" + postgresql: '9.6' before-script: - psql -c 'CREATE DATABASE test;' -U postgres - - python manage.py makemigrations + - python manage.py makemigrations - python manage.py migrate script: diff --git a/authors/apps/authentication/backends.py b/authors/apps/authentication/backends.py index 65497bdc..3064fce2 100644 --- a/authors/apps/authentication/backends.py +++ b/authors/apps/authentication/backends.py @@ -1,9 +1,10 @@ +import jwt + from django.conf import settings from django.http import HttpResponse from rest_framework.authentication import BaseAuthentication from rest_framework.authentication import get_authorization_header from rest_framework import status, exceptions -import jwt from .models import User @@ -50,3 +51,4 @@ def authenticate_credentials(self, token): status=status.HTTP_403_FORBIDDEN) return user, token + \ No newline at end of file diff --git a/authors/apps/authentication/migrations/0001_initial.py b/authors/apps/authentication/migrations/0001_initial.py index 6e37a19a..f5f4ee2b 100644 --- a/authors/apps/authentication/migrations/0001_initial.py +++ b/authors/apps/authentication/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 2.1.4 on 2019-01-08 14:41 +# Generated by Django 2.1.4 on 2019-01-18 07:38 from django.db import migrations, models diff --git a/authors/apps/authentication/serializers.py b/authors/apps/authentication/serializers.py index a50244b3..cce840cc 100644 --- a/authors/apps/authentication/serializers.py +++ b/authors/apps/authentication/serializers.py @@ -195,3 +195,10 @@ def update(self, instance, validated_data): instance.save() return instance + + +class SocialAuthSerializer(serializers.Serializer): + """Social auth serializers.""" + provider = serializers.CharField(max_length=255, required=True) + access_token = serializers.CharField(max_length=1024, required=True, trim_whitespace=True) + \ No newline at end of file diff --git a/authors/apps/authentication/tests/test_social_auth.py b/authors/apps/authentication/tests/test_social_auth.py new file mode 100644 index 00000000..c3f8e3af --- /dev/null +++ b/authors/apps/authentication/tests/test_social_auth.py @@ -0,0 +1,87 @@ +import json +import os +from django.urls import reverse +from rest_framework.views import status +from rest_framework.test import APITestCase, APIClient + + +class SocialAuthTest(APITestCase): + """Test social authentication funcionality.""" + client = APIClient + + def setUp(self): + self.social_oauth_url = reverse('authentication:social_auth') + self.google_access_token = os.getenv('GOOGLE_TOKEN') + self.facebook_access_token = os.getenv('FACEBOOK_TOKEN') + self.oauth1_access_token = os.getenv('TWITTER_TOKEN') + self.oauth1_access_token_secret = os.getenv('TWITTER_SECRET_TOKEN') + + self.invalid_provider = { + "provider": "google-oauth21", + "access_token": self.google_access_token + + } + self.google_provider = { + "provider": "google-oauth2", + "access_token": self.google_access_token + } + self.facebook_provider = { + "provider": "facebook", + "access_token": self.facebook_access_token + } + self.twitter_provider = { + "provider": "twitter", + "access_token": self.oauth1_access_token, + "access_token_secret": self.oauth1_access_token_secret + } + + def test_rejects_invalid_provider_name(self): + """Test invalid provider name.""" + response = self.client.post( + self.social_oauth_url, self.invalid_provider, format='json') + self.assertEqual(response.data['error'], + 'Please enter a valid social provider') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_login_with_google(self): + """Test login with google""" + response = self.client.post( + self.social_oauth_url, self.google_provider, format='json') + self.assertIn('email', response.data) + self.assertIn('token', response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_with_facebook(self): + """Test login with facebook.""" + response = self.client.post( + self.social_oauth_url, self.facebook_provider, format='json') + self.assertIn('email', response.data) + self.assertIn('token', response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_login_with_twitter(self): + """Test login with twitter.""" + response = self.client.post( + self.social_oauth_url, self.twitter_provider, format='json') + self.assertIn('email', response.data) + self.assertIn('token', response.data) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_rejects_login_missing_access_token(self): + """Test missing token.""" + response = self.client.post(self.social_oauth_url, + data={"provider": 'facebook'}, + format='json') + self.assertEqual(json.loads(response.content), + {"errors": {"access_token": + ["This field is required."]}}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + + def test_rejects_login_missing_provider_name(self): + """Test missing provider.""" + response = self.client.post( + self.social_oauth_url, + data={"access_token": self.google_access_token}, format='json') + self.assertEqual(json.loads(response.content), {"errors": {"provider": + ["This field is required."]}}) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) diff --git a/authors/apps/authentication/urls.py b/authors/apps/authentication/urls.py index 5d4ce272..aacf798b 100644 --- a/authors/apps/authentication/urls.py +++ b/authors/apps/authentication/urls.py @@ -3,7 +3,8 @@ from .views import ( LoginAPIView, RegistrationAPIView, UserRetrieveUpdateAPIView, - ResetPasswordAPIView, UpdatePasswordAPIView, VerifyAPIView,ResendVerifyAPIView + ResetPasswordAPIView, UpdatePasswordAPIView, VerifyAPIView, + ResendVerifyAPIView, SocialAuthenticationView ) app_name = "authentication" @@ -15,6 +16,5 @@ path('users/passwordresetdone/', UpdatePasswordAPIView.as_view(), name="passwordresetdone"), path('users/verify//', VerifyAPIView.as_view(), name="auth-verify"), path('users/resend-verification/', ResendVerifyAPIView.as_view(), name="auth-reverify"), - - + path('login/oauth/', SocialAuthenticationView.as_view(), name="social_auth") ] diff --git a/authors/apps/authentication/views.py b/authors/apps/authentication/views.py index 53f22425..66cc714d 100644 --- a/authors/apps/authentication/views.py +++ b/authors/apps/authentication/views.py @@ -1,25 +1,32 @@ import os import jwt from datetime import datetime, timedelta +from social_django.utils import load_strategy, load_backend +from social_core.exceptions import MissingBackend, AuthAlreadyAssociated +from social_core.backends.oauth import BaseOAuth1, BaseOAuth2 + from django.conf import settings from django.core.mail import send_mail from django.template.loader import render_to_string +from django.db import IntegrityError from rest_framework import status, generics from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response from rest_framework.views import APIView -from rest_framework.renderers import BrowsableAPIRenderer -from .renderers import UserJSONRenderer + + from . import models +from .renderers import UserJSONRenderer from .serializers import ( LoginSerializer, RegistrationSerializer, UserSerializer, - ResetSerializerEmail, ResetSerializerPassword + ResetSerializerEmail, ResetSerializerPassword, SocialAuthSerializer ) from .models import User from .utils import send_link class RegistrationAPIView(generics.CreateAPIView): + # Allow any user (authenticated or not) to hit this endpoint. permission_classes = (AllowAny,) renderer_classes = (UserJSONRenderer,) @@ -194,4 +201,70 @@ def put(self, request, token, **kwargs): except jwt.ExpiredSignatureError: return Response({"The link expired"}, status=status.HTTP_400_BAD_REQUEST) - \ No newline at end of file + + +class SocialAuthenticationView(generics.CreateAPIView): + """Social authentication.""" + permission_classes = (AllowAny,) + serializer_class = SocialAuthSerializer + render_classes = (UserJSONRenderer,) + + def create(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + provider = request.data['provider'] + strategy = load_strategy(request) + authenticated_user = request.user if not request.user.is_anonymous else None + + try: + backend = load_backend( + strategy=strategy, + name=provider, + redirect_uri=None + ) + + if isinstance(backend, BaseOAuth1): + if "access_token_secret" in request.data: + token = { + 'oauth_token': request.data['access_token'], + 'oauth_token_secret': + request.data['access_token_secret'] + } + else: + return Response({'error': + 'Please enter your secret token'}, + status=status.HTTP_400_BAD_REQUEST) + elif isinstance(backend, BaseOAuth2): + token = serializer.data.get('access_token') + except MissingBackend: + return Response({ + 'error': 'Please enter a valid social provider' + }, status=status.HTTP_400_BAD_REQUEST) + + try: + user = backend.do_auth(token, user=authenticated_user) + except (AuthAlreadyAssociated, IntegrityError): + return Response({ + "errors": "You are already logged in with another account"}, + status=status.HTTP_400_BAD_REQUEST) + except BaseException: + return Response({ + "errors": "Invalid token"}, + status=status.HTTP_500_INTERNAL_SERVER_ERROR) + if user: + user.is_active = True + username = user.username + email = user.email + + date = datetime.now() + timedelta(days=20) + payload = { + 'email': email, + 'username': username, + 'exp': int(date.strftime('%s')) + } + user_token = jwt.encode( + payload, settings.SECRET_KEY, algorithm='HS256') + serializer = UserSerializer(user) + serialized_details = serializer.data + serialized_details["token"] = user_token + return Response(serialized_details, status.HTTP_200_OK) diff --git a/authors/settings.py b/authors/settings.py index 4116953f..9f8d225a 100644 --- a/authors/settings.py +++ b/authors/settings.py @@ -42,11 +42,17 @@ 'rest_framework', 'taggit', + 'authors.apps.authentication', 'authors.apps.core', 'authors.apps.profiles', 'authors.apps.friends', 'authors.apps.articles', + 'oauth2_provider', + 'social_django', + 'rest_framework_social_oauth2', + + # Enables API to be documented using Swagger 'rest_framework_swagger', @@ -153,7 +159,40 @@ 'authors.apps.authentication.backends.JWTAuthentication', ), } +AUTHENTICATION_BACKENDS = ( + 'social_core.backends.google.GoogleOAuth2', + 'social_core.backends.twitter.TwitterOAuth', + 'social_core.backends.facebook.FacebookOAuth2', + 'django.contrib.auth.backends.ModelBackend', +) + +SOCIAL_AUTH_FACEBOOK_KEY = os.getenv('FACEBOOK_APP_ID') +SOCIAL_AUTH_FACEBOOK_SECRET = os.getenv('FACEBOOK_APP_SECRET') +SOCIAL_AUTH_FACEBOOK_SCOPE = ['email'] +SOCIAL_AUTH_FACEBOOK_PROFILE_EXTRA_PARAMS = { + 'fields': 'id, name, email' +} +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = os.getenv('OAUTH2_KEY') +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = os.getenv('OAUTH2_SECRET') +SOCIAL_AUTH_GOOGLE_OAUTH_SCOPE = ['email', 'username', 'password'] + +SOCIAL_AUTH_TWITTER_KEY = os.getenv('TWITTER_KEY') +SOCIAL_AUTH_TWITTER_SECRET = os.getenv('TWITTER_SECRET') +SOCIAL_AUTH_TWITTER_SCOPE = ['email'] + +SOCIAL_AUTH_PIPELINE = ( + 'social_core.pipeline.social_auth.social_details', + 'social_core.pipeline.social_auth.social_uid', + 'social_core.pipeline.social_auth.auth_allowed', + 'social_core.pipeline.social_auth.social_user', + 'social_core.pipeline.user.get_username', + 'social_core.pipeline.social_auth.associate_by_email', + 'social_core.pipeline.user.create_user', + 'social_core.pipeline.social_auth.associate_user', + 'social_core.pipeline.social_auth.load_extra_data', + 'social_core.pipeline.user.user_details', +) django_heroku.settings(locals()) # Set the settings for Swagger documentation diff --git a/requirements.txt b/requirements.txt index b28c9dea..c2e8fd0a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,8 @@ six==1.12.0 pytz==2018.9 cloudinary==1.15.0 Pillow==5.4.1 +social-auth-app-django==3.1.0 +social-auth-core==3.0.0 +django-rest-framework-social-oauth2==1.1.0 +django-oauth-toolkit==1.2.0 +coverage==4.5.2