Skip to content

Commit

Permalink
ZDL-97: Integrate Google OAuth login
Browse files Browse the repository at this point in the history
  • Loading branch information
RyanAquino committed Feb 20, 2022
1 parent beef8da commit 3fe6a6a
Show file tree
Hide file tree
Showing 20 changed files with 344 additions and 2 deletions.
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ Zadala API is an ecommerce web API built with django rest framework.
- pytest
- django rest framework
- PostgreSQL
- Redis
- OAuth2


### Setup
Expand Down Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions authentication/migrations/0002_user_auth_provider.py
Original file line number Diff line number Diff line change
@@ -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,
),
),
]
9 changes: 8 additions & 1 deletion authentication/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions authentication/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -107,6 +110,7 @@ class Meta:
"first_name",
"last_name",
"last_login",
"auth_provider",
"date_joined",
"password",
]
Expand Down
1 change: 1 addition & 0 deletions authentication/tests/factories/user_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
55 changes: 55 additions & 0 deletions authentication/tests/test_auth_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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="[email protected]", 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):
"""
Expand Down Expand Up @@ -151,6 +168,7 @@ def test_retrieve_user_profile():
assert data["first_name"] == "test"
assert data["last_name"] == "test2"
assert data["email"] == "[email protected]"
assert data["auth_provider"] == "email"
assert data.get("password") is None


Expand Down Expand Up @@ -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="[email protected]",
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
13 changes: 13 additions & 0 deletions authentication/validators.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from enum import Enum

from pydantic import BaseModel


Expand All @@ -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
)
3 changes: 2 additions & 1 deletion authentication/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ def get(self, request):
"first_name",
"last_name",
"last_login",
"auth_provider",
"date_joined",
),
)
Expand All @@ -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)
11 changes: 11 additions & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
Empty file added social_auth/__init__.py
Empty file.
5 changes: 5 additions & 0 deletions social_auth/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from django.apps import AppConfig


class SocialAuthConfig(AppConfig):
name = "social_auth"
18 changes: 18 additions & 0 deletions social_auth/google.py
Original file line number Diff line number Diff line change
@@ -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")
22 changes: 22 additions & 0 deletions social_auth/serializers.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added social_auth/tests/__init__.py
Empty file.
Loading

0 comments on commit 3fe6a6a

Please sign in to comment.