From 3fe6a6a3ace4a326cceb2e02b8611b523c8fc007 Mon Sep 17 00:00:00 2001 From: RyanAquino Date: Wed, 16 Feb 2022 23:04:47 +0800 Subject: [PATCH] ZDL-97: Integrate Google OAuth login --- README.md | 7 ++ .../migrations/0002_user_auth_provider.py | 27 +++++ authentication/models.py | 9 +- authentication/serializers.py | 4 + .../tests/factories/user_factory.py | 1 + authentication/tests/test_auth_api.py | 55 ++++++++++ authentication/validators.py | 13 +++ authentication/views.py | 3 +- requirements.txt | 11 ++ social_auth/__init__.py | 0 social_auth/apps.py | 5 + social_auth/google.py | 18 +++ social_auth/serializers.py | 22 ++++ social_auth/tests/__init__.py | 0 social_auth/tests/test_social_auth_api.py | 103 ++++++++++++++++++ social_auth/urls.py | 5 + social_auth/views.py | 53 +++++++++ zadalaAPI/settings.py | 6 + zadalaAPI/urls.py | 1 + zadala_config.py | 3 + 20 files changed, 344 insertions(+), 2 deletions(-) create mode 100644 authentication/migrations/0002_user_auth_provider.py create mode 100644 social_auth/__init__.py create mode 100644 social_auth/apps.py create mode 100644 social_auth/google.py create mode 100644 social_auth/serializers.py create mode 100644 social_auth/tests/__init__.py create mode 100644 social_auth/tests/test_social_auth_api.py create mode 100644 social_auth/urls.py create mode 100644 social_auth/views.py diff --git a/README.md b/README.md index ceb564b..4d82b46 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,8 @@ Zadala API is an ecommerce web API built with django rest framework. - pytest - django rest framework - PostgreSQL +- Redis +- OAuth2 ### Setup @@ -44,6 +46,11 @@ python manage.py createsuperuser python manage.py runserver ``` +##### Running RQ workers +``` +python manage.py rqworker high default low +``` + #### Access on browser ``` http://localhost:8000/api-docs diff --git a/authentication/migrations/0002_user_auth_provider.py b/authentication/migrations/0002_user_auth_provider.py new file mode 100644 index 0000000..b42f9d6 --- /dev/null +++ b/authentication/migrations/0002_user_auth_provider.py @@ -0,0 +1,27 @@ +# Generated by Django 3.0.8 on 2022-02-12 15:43 + +from django.db import migrations, models + +import authentication.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ("authentication", "0001_initial"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="auth_provider", + field=models.CharField( + choices=[ + (authentication.validators.AuthProviders["google"], "google"), + (authentication.validators.AuthProviders["email"], "email"), + ], + default="email", + max_length=255, + ), + ), + ] diff --git a/authentication/models.py b/authentication/models.py index e853638..628fbb0 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -7,7 +7,7 @@ from django.db import models from rest_framework_simplejwt.tokens import RefreshToken -from authentication.validators import UserTokens +from authentication.validators import AuthProviders, UserTokens class UserManager(BaseUserManager): @@ -49,6 +49,13 @@ class User(AbstractBaseUser, PermissionsMixin): password = models.CharField(max_length=255) last_login = models.DateTimeField(auto_now=True) date_joined = models.DateTimeField(auto_now_add=True) + auth_provider = models.CharField( + max_length=255, + blank=False, + null=False, + default=AuthProviders.email.value, + choices=AuthProviders.valid_providers(), + ) is_staff = models.BooleanField(default=False) is_active = models.BooleanField(default=True) diff --git a/authentication/serializers.py b/authentication/serializers.py index 9d85399..a3fa134 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -85,6 +85,9 @@ def validate(self, attrs) -> UserLogin: if not user: raise AuthenticationFailed("Invalid email/password") + if user.auth_provider != "email": + raise AuthenticationFailed("Please login using your login provider.") + tokens = user.tokens() return UserLogin( @@ -107,6 +110,7 @@ class Meta: "first_name", "last_name", "last_login", + "auth_provider", "date_joined", "password", ] diff --git a/authentication/tests/factories/user_factory.py b/authentication/tests/factories/user_factory.py index e18df8a..d95a7e7 100644 --- a/authentication/tests/factories/user_factory.py +++ b/authentication/tests/factories/user_factory.py @@ -14,6 +14,7 @@ class Meta: last_name = "account" password = PostGenerationMethodCall("set_password", "password") is_active = True + auth_provider = "email" @factory.post_generation def groups(self, create, extracted, **kwargs): diff --git a/authentication/tests/test_auth_api.py b/authentication/tests/test_auth_api.py index c786ff5..a6be0d7 100644 --- a/authentication/tests/test_auth_api.py +++ b/authentication/tests/test_auth_api.py @@ -81,6 +81,23 @@ def test_user_login(client): assert response.status_code == 200 +@pytest.mark.django_db +def test_user_login_with_google_provider(client): + """ + Test User login with email on existing OAuth user + """ + user = UserFactory( + email="test@email.com", password="temp-password", auth_provider="google" + ) + data = {"email": user.email, "password": "temp-password"} + + response = client.post("/v1/auth/login/", data) + response_data = response.json() + + assert response.status_code == 403 + assert response_data == {"detail": "Please login using your login provider."} + + @pytest.mark.django_db def test_invalid_credentials_user_login(client): """ @@ -151,6 +168,7 @@ def test_retrieve_user_profile(): assert data["first_name"] == "test" assert data["last_name"] == "test2" assert data["email"] == "test_test2@email.com" + assert data["auth_provider"] == "email" assert data.get("password") is None @@ -191,3 +209,40 @@ def test_patch_profile_details(): assert modified_user.first_name == "modified_name1" assert modified_user.last_name == "modified_name2" assert modified_user.check_password("test2") is True + + +@pytest.mark.django_db +def test_patch_profile_password_of_oauth_user_should_not_update(): + """ + Test patch user profile password of an existing oauth user should not update the password + """ + content_type = MULTIPART_CONTENT + mock_logged_in_user = UserFactory( + email="test_test2@email.com", + first_name="test", + last_name="test2", + auth_provider="google", + password="oauth-generated-password", + groups=Group.objects.all(), + ) + user_token = mock_logged_in_user.tokens().token + client = Client(HTTP_AUTHORIZATION=f"Bearer {user_token}") + modified_data = { + "password": "oauth-modified-password", + } + + data = client._encode_json({} if not modified_data else modified_data, content_type) + encoded_data = client._encode_data(data, content_type) + response = client.generic( + "PATCH", + "/v1/auth/profile/", + encoded_data, + content_type=content_type, + secure=False, + enctype="multipart/form-data", + ) + + modified_user = User.objects.first() + + assert response.status_code == 204 + assert modified_user.check_password("oauth-generated-password") is True diff --git a/authentication/validators.py b/authentication/validators.py index a984f0d..6db18af 100644 --- a/authentication/validators.py +++ b/authentication/validators.py @@ -1,3 +1,5 @@ +from enum import Enum + from pydantic import BaseModel @@ -12,3 +14,14 @@ class UserLogin(BaseModel): class UserTokens(BaseModel): token: str refresh: str + + +class AuthProviders(str, Enum): + google = "google" + email = "email" + + @staticmethod + def valid_providers(): + return ( + (getattr(AuthProviders, item.value), item.value) for item in AuthProviders + ) diff --git a/authentication/views.py b/authentication/views.py index 71487c2..a2ea4ce 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -67,6 +67,7 @@ def get(self, request): "first_name", "last_name", "last_login", + "auth_provider", "date_joined", ), ) @@ -78,7 +79,7 @@ def patch(self, request): ) serializer.is_valid(raise_exception=True) password = serializer.validated_data.pop("password", None) - if password: + if password and request.user.auth_provider == "email": request.user.set_password(password) serializer.save() return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/requirements.txt b/requirements.txt index f857933..712046d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ asgiref==3.2.10 atomicwrites==1.4.0 attrs==19.3.0 black==20.8b1 +cachetools==5.0.0 certifi==2020.6.20 chardet==3.0.4 charset-normalizer==2.0.9 @@ -23,7 +24,13 @@ factory-boy==3.2.0 Faker==8.8.0 filelock==3.4.0 flake8==3.8.3 +google-api-core==2.5.0 +google-api-python-client==2.37.0 +google-auth==2.6.0 +google-auth-httplib2==0.1.0 +googleapis-common-protos==1.54.0 gunicorn==20.0.4 +httplib2==0.20.4 idna==3.0 importlib-metadata==1.7.0 inflection==0.5.1 @@ -40,9 +47,12 @@ packaging==20.4 pathspec==0.8.0 Pillow==7.2.0 pluggy==0.13.1 +protobuf==3.19.4 psycopg2-binary==2.8.6 py==1.9.0 py3-validate-email==1.0.5 +pyasn1==0.4.8 +pyasn1-modules==0.2.8 pycodestyle==2.6.0 pydantic==1.6.1 pyflakes==2.2.0 @@ -58,6 +68,7 @@ regex==2020.7.14 requests==2.26.0 rest-condition==1.0.3 rq==1.10.1 +rsa==4.8 ruamel.yaml==0.16.12 ruamel.yaml.clib==0.2.2 six==1.15.0 diff --git a/social_auth/__init__.py b/social_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_auth/apps.py b/social_auth/apps.py new file mode 100644 index 0000000..14accb6 --- /dev/null +++ b/social_auth/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + + +class SocialAuthConfig(AppConfig): + name = "social_auth" diff --git a/social_auth/google.py b/social_auth/google.py new file mode 100644 index 0000000..56259f4 --- /dev/null +++ b/social_auth/google.py @@ -0,0 +1,18 @@ +from google.auth.transport import requests +from google.oauth2 import id_token + + +class Google: + @staticmethod + def validate(auth_token): + """ + validate method Queries the Google oAUTH2 api to fetch the user info + """ + try: + id_info = id_token.verify_oauth2_token(auth_token, requests.Request()) + + if "accounts.google.com" in id_info["iss"]: + return id_info + + except ValueError: + raise ValueError("The token is either invalid or has expired") diff --git a/social_auth/serializers.py b/social_auth/serializers.py new file mode 100644 index 0000000..254bdc6 --- /dev/null +++ b/social_auth/serializers.py @@ -0,0 +1,22 @@ +from django.conf import settings +from rest_framework import serializers +from rest_framework.exceptions import AuthenticationFailed + +from social_auth.google import Google + + +class GoogleSocialAuthSerializer(serializers.Serializer): + auth_token = serializers.CharField() + + def validate_auth_token(self, auth_token): + try: + user_data = Google.validate(auth_token) + except ValueError: + raise serializers.ValidationError( + "The token is invalid or expired. Please login again." + ) + + if user_data.get("aud") != settings.GOOGLE_CLIENT_ID: + raise AuthenticationFailed("Please login using a valid Google token.") + + return user_data diff --git a/social_auth/tests/__init__.py b/social_auth/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/social_auth/tests/test_social_auth_api.py b/social_auth/tests/test_social_auth_api.py new file mode 100644 index 0000000..4aa0ea3 --- /dev/null +++ b/social_auth/tests/test_social_auth_api.py @@ -0,0 +1,103 @@ +from unittest.mock import patch + +import pytest + +from authentication.models import User +from authentication.tests.factories.user_factory import UserFactory +from social_auth.google import Google + + +@pytest.mark.django_db +@patch("google.oauth2.id_token.verify_oauth2_token") +@patch("django.conf.settings.GOOGLE_CLIENT_ID", 1) +def test_social_login_non_existing_account(mocked_oauth_details, client): + """ + Test Social User login should create and return tokens for account + """ + data = {"auth_token": "some-oauth-token"} + mocked_oauth_details.return_value = { + "iss": "accounts.google.com", + "aud": 1, + "email": "test-user@gmail.com", + "given_name": "given-name-test-user", + "family_name": "family-name-test-user", + } + + response = client.post("/v1/social-auth/google/", data, format="json") + response_data = response.json() + + assert User.objects.filter(email="test-user@gmail.com").exists() is True + assert response.status_code == 200 + assert response_data["email"] == "test-user@gmail.com" + assert response_data.get("access") and response_data.get("refresh") + + +@pytest.mark.django_db +@patch("google.oauth2.id_token.verify_oauth2_token") +@patch("django.conf.settings.GOOGLE_CLIENT_ID", 1) +@patch("django.conf.settings.GOOGLE_CLIENT_SECRET", "temp-oauth-password") +def test_social_login_on_existing_account(mocked_oauth_details, client): + """ + test Social user login on an existing account should return tokens + """ + user = UserFactory(email="test@gmail.com", password="temp-oauth-password") + data = {"auth_token": "some-oauth-token"} + mocked_oauth_details.return_value = { + "iss": "accounts.google.com", + "aud": 1, + "email": user.email, + } + + response = client.post("/v1/social-auth/google/", data, format="json") + response_data = response.json() + + assert response.status_code == 200 + assert response_data["email"] == "test@gmail.com" + assert response_data.get("access") and response_data.get("refresh") + + +@patch("social_auth.google.Google.validate") +def test_social_login_invalid_token(mocked_google_validate, client): + """ + Test social login with invalid/expired token + """ + mocked_google_validate.side_effect = ValueError("mocked error") + data = {"auth_token": "test-oauth-invalid-token"} + + response = client.post("/v1/social-auth/google/", data, format="json") + response_data = response.json() + + assert response.status_code == 400 + assert response_data == { + "auth_token": ["The token is invalid or expired. Please login again."] + } + + +@patch("django.conf.settings.GOOGLE_CLIENT_ID", "some-random-number") +@patch("google.oauth2.id_token.verify_oauth2_token") +def test_social_login_invalid_token_audience_mismatch(mocked_oauth_details, client): + """ + Test social login with invalid Google token + """ + data = {"auth_token": "test-oauth-invalid-token"} + mocked_oauth_details.return_value = { + "iss": "accounts.google.com", + "aud": "another-random-number", + "email": "test-user@gmail.com", + "given_name": "given-name-test-user", + "family_name": "family-name-test-user", + } + + response = client.post("/v1/social-auth/google/", data, format="json") + response_data = response.json() + + assert response.status_code == 403 + assert response_data == {"detail": "Please login using a valid Google token."} + + +def test_google_validate_on_invalid_token(): + """ + Test Google token validator should raise ValueError on invalid token + """ + with pytest.raises(ValueError): + Google.validate("some-invalid-token") diff --git a/social_auth/urls.py b/social_auth/urls.py new file mode 100644 index 0000000..04d7730 --- /dev/null +++ b/social_auth/urls.py @@ -0,0 +1,5 @@ +from django.urls import path + +from social_auth.views import GoogleSocialAuthView + +urlpatterns = [path("google/", GoogleSocialAuthView.as_view())] diff --git a/social_auth/views.py b/social_auth/views.py new file mode 100644 index 0000000..c0d645d --- /dev/null +++ b/social_auth/views.py @@ -0,0 +1,53 @@ +from django.conf import settings +from django.contrib.auth import authenticate +from rest_framework import status +from rest_framework.generics import GenericAPIView +from rest_framework.response import Response + +from authentication.models import User +from authentication.validators import AuthProviders, UserLogin +from social_auth.serializers import GoogleSocialAuthSerializer + + +class GoogleSocialAuthView(GenericAPIView): + authentication_classes: list = [] + permission_classes: list = [] + serializer_class = GoogleSocialAuthSerializer + + def post(self, request): + """ + POST with "auth_token" + Send an idtoken as from google to get user information + """ + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + validated_user_data = serializer.validated_data.get("auth_token") + oauth_user_password = settings.GOOGLE_CLIENT_SECRET + user_data = { + "email": validated_user_data.get("email"), + "first_name": validated_user_data.get("given_name"), + "last_name": validated_user_data.get("family_name"), + "password": oauth_user_password, + "role": "Customers", + } + + try: + user = User.objects.get(email=user_data.get("email")) + except User.DoesNotExist: + user = User.objects.create_user(**user_data) + user.auth_provider = AuthProviders.google.value + user.save() + + user = authenticate(email=user.email, password=oauth_user_password) + tokens = user.tokens() + user_details = UserLogin( + **{ + "email": user.email, + "first_name": user.first_name, + "last_name": user.last_name, + "access": tokens.token, + "refresh": tokens.refresh, + } + ) + + return Response(user_details.dict(), status=status.HTTP_200_OK) diff --git a/zadalaAPI/settings.py b/zadalaAPI/settings.py index 6e9f429..e4a3c5d 100644 --- a/zadalaAPI/settings.py +++ b/zadalaAPI/settings.py @@ -17,6 +17,8 @@ EMAIL_HOST_PORT, EMAIL_HOST_PROVIDER, EMAIL_HOST_USER, + GOOGLE_CLIENT_ID, + GOOGLE_CLIENT_SECRET, ZADALA_SECRET_KEY, database, redis_database, @@ -55,6 +57,7 @@ "authentication", "rest_framework", "django_rq", + "social_auth", ] MIDDLEWARE = [ @@ -212,3 +215,6 @@ "URL": os.getenv("REDISTOGO_URL", redis_database["REDIS_URL"]), }, } + +GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", GOOGLE_CLIENT_ID) +GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", GOOGLE_CLIENT_SECRET) diff --git a/zadalaAPI/urls.py b/zadalaAPI/urls.py index 54fa026..d0235ad 100644 --- a/zadalaAPI/urls.py +++ b/zadalaAPI/urls.py @@ -41,6 +41,7 @@ path("v1/products/", include("products.urls")), path("v1/orders/", include("orders.urls")), path("v1/auth/", include("authentication.urls")), + path("v1/social-auth/", include("social_auth.urls")), path("v1/auth/token/refresh/", TokenRefreshView.as_view(), name="token_refresh"), path( "api-docs", diff --git a/zadala_config.py b/zadala_config.py index 65d2776..e90074d 100644 --- a/zadala_config.py +++ b/zadala_config.py @@ -17,3 +17,6 @@ EMAIL_HOST_PORT = 587 EMAIL_HOST_USER = "gmail email" EMAIL_HOST_PASSWORD = "gmail password" + +GOOGLE_CLIENT_ID = "Google client ID" +GOOGLE_CLIENT_SECRET = "Google client secret"