From b0911ea1d2448ca2a8bcbf9859dc31271261fb07 Mon Sep 17 00:00:00 2001 From: Yuvraj Rathva Date: Sat, 23 Sep 2023 03:31:01 +0530 Subject: [PATCH] adding google signup and signin - no test --- .env.example | 23 +++ accounts/urls.py | 7 +- accounts/utils.py | 42 +++++ accounts/views.py | 365 +++++++++++++++++++++++++++++++++++++++++++- backend/settings.py | 44 +++++- 5 files changed, 475 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index a39262a..95ceda7 100644 --- a/.env.example +++ b/.env.example @@ -11,4 +11,27 @@ POSTGRES_DB_PASSWORD=festpassword POSTGRES_DB_HOST=localhost POSTGRES_DB_PORT=5432 +BASE_FRONTEND_URL=http://localhost:3000 +BASE_BACKEND_URL=http://localhost:8000 + DEFAULT_PROFILE_IMAGE_URL="https://as1.ftcdn.net/v2/jpg/03/46/83/96/1000_F_346839683_6nAPzbhpSkIpb8pmAwufkC7c5eD7wYws.jpg" + +# Razorpay +RAZORPAY_KEY_ID= +RAZORPAY_KEY_SECRET= + +# Google Sign-in +DJANGO_GOOGLE_OAUTH2_CLIENT_ID= +DJANGO_GOOGLE_OAUTH2_CLIENT_SECRET= +APP_SECRET= + +# Email configs +EMAIL_HOST="" +EMAIL_PORT="" +EMAIL_HOST_USER="" +EMAIL_HOST_PASSWORD="" + +PAYTM_MID= +PAYTM_MERCHANT_KEY= + +LOG_FILE= \ No newline at end of file diff --git a/accounts/urls.py b/accounts/urls.py index 63eccc2..958733b 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -3,7 +3,7 @@ from rest_framework_simplejwt.views import (TokenObtainPairView, TokenRefreshView) -from .views import MyObtainTokenPairView, UserProfileViewSet, UserDetailAPI, UserProfileDetailsView, RegisterUserAPIView, CookieTokenRefreshView, PreRegistrationAPIView +from .views import MyObtainTokenPairView, UserProfileViewSet, UserDetailAPI, UserProfileDetailsView, RegisterUserAPIView, CookieTokenRefreshView, PreRegistrationAPIView, GoogleRegisterView, GoogleLoginView, GoogleRegisterViewApp, GoogleLoginViewApp, LogoutView router = routers.DefaultRouter() router.register(r'pre-register', PreRegistrationAPIView) @@ -12,6 +12,11 @@ path('', include(router.urls)), path('register/', RegisterUserAPIView.as_view(), name="register"), path('login/', MyObtainTokenPairView.as_view(), name='login'), + path('register/google/', GoogleRegisterView.as_view(), name='google-register'), + path('register/google/app/', GoogleRegisterViewApp.as_view(), name='google-register-app'), + path('login/google/', GoogleLoginView.as_view(), name='google-login'), + path('login/google/app/', GoogleLoginViewApp.as_view(), name='google-login-app'), + path('logout/', LogoutView.as_view(), name='logout'), path('profile/', UserProfileViewSet.as_view(), name='profile'), path('refresh/', CookieTokenRefreshView.as_view(), name='refresh'), path('user-details/', UserDetailAPI.as_view()), diff --git a/accounts/utils.py b/accounts/utils.py index eb7a4a3..51b2c4c 100644 --- a/accounts/utils.py +++ b/accounts/utils.py @@ -1,6 +1,15 @@ +import requests +from django.conf import settings +from typing import Dict, Any +from django.core.exceptions import ValidationError from rest_framework_simplejwt.tokens import RefreshToken +GOOGLE_ID_TOKEN_INFO_URL = 'https://www.googleapis.com/oauth2/v3/tokeninfo' +GOOGLE_ACCESS_TOKEN_OBTAIN_URL = 'https://oauth2.googleapis.com/token' +GOOGLE_USER_INFO_URL = 'https://www.googleapis.com/oauth2/v3/userinfo' + + def get_tokens_for_user(user): refresh = RefreshToken.for_user(user) return { @@ -9,5 +18,38 @@ def get_tokens_for_user(user): } +def google_get_access_token(*, code: str, redirect_uri: str) -> str: + # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#obtainingaccesstokens + data = { + 'code': code, + 'client_id': settings.GOOGLE_OAUTH2_CLIENT_ID, + 'client_secret': settings.GOOGLE_OAUTH2_CLIENT_SECRET, + 'redirect_uri': redirect_uri, + 'grant_type': 'authorization_code' + } + + response = requests.post(GOOGLE_ACCESS_TOKEN_OBTAIN_URL, data=data) + + if not response.ok: + raise ValidationError('Failed to obtain access token from Google.') + + access_token = response.json()['access_token'] + + return access_token + + +def google_get_user_info(*, access_token: str) -> Dict[str, Any]: + # Reference: https://developers.google.com/identity/protocols/oauth2/web-server#callinganapi + response = requests.get( + GOOGLE_USER_INFO_URL, + params={'access_token': access_token} + ) + + if not response.ok: + raise ValidationError('Failed to obtain user info from Google.') + + return response.json() + + def generate_registration_code(name, lastRegCode): return f"{name[:3].upper()}-{lastRegCode+3155}" diff --git a/accounts/views.py b/accounts/views.py index 1192386..90be1fc 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,13 +1,17 @@ import datetime +from django.conf import settings +from urllib.parse import urlencode +from django.shortcuts import redirect from django.contrib.auth import get_user_model, authenticate from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView -from rest_framework import generics, status, viewsets +from rest_framework import generics, status, viewsets, exceptions, serializers from rest_framework.views import APIView from rest_framework.response import Response -from .models import UserProfile, PreRegistration +from .models import UserProfile, PreRegistration, BlacklistedEmail from .serializers import MyTokenObtainPairSerializer, UserProfileSerializer, UserSerializer, RegisterSerializer, CookieTokenRefreshSerializer, PreRegistrationSerializer -from .utils import get_tokens_for_user +from .utils import get_tokens_for_user, google_get_access_token, google_get_user_info User = get_user_model() @@ -161,3 +165,358 @@ def finalize_response(self, request, response, *args, **kwargs): ) return super().finalize_response(request, response, *args, **kwargs) + + +class GoogleRegisterView(APIView): + class InputSerializer(serializers.Serializer): + code = serializers.CharField(required=False) + error = serializers.CharField(required=False) + + def get(self, request, *args, **kwargs): + input_serializer = self.InputSerializer(data=request.GET) + input_serializer.is_valid(raise_exception=True) + + validated_data = input_serializer.validated_data + + code = validated_data.get('code') + error = validated_data.get('error') + + login_url = f'{settings.BASE_FRONTEND_URL}/login' + + if error or not code: + params = urlencode({'Error': error}) + return redirect(f'{login_url}?{params}') + + redirect_uri = f'{settings.BASE_BACKEND_URL}/accounts/login/google/' + + try: + access_token = google_get_access_token(code=code, redirect_uri=redirect_uri) + except Exception: + params = urlencode({'Error': "Failed to obtain access token from Google."}) + return redirect(f'{login_url}?{params}') + + try: + user_data = google_get_user_info(access_token=access_token) + except Exception: + params = urlencode({'Error': "Failed to obtain user info from Google."}) + return redirect(f'{login_url}?{params}') + + if BlacklistedEmail.objects.filter(email=user_data["email"]).exists(): + params = urlencode({'Error': "This email is blacklisted"}) + return redirect(f'{login_url}?{params}') + + try: + user = User.objects.create( + username=user_data['email'], + email=user_data['email'], + first_name=user_data.get('given_name', ''), + last_name=user_data.get('family_name', ''), + google_picture=user_data.get('picture', ''), + is_google=True, + ) + user.set_password('google') + + if user_data['email'][-10:] == "iitj.ac.in": + user.iitj = True + + user.save() + except Exception: + params = urlencode({'Error': "User already exists, try to sign-in!"}) + return redirect(f'{login_url}?{params}') + + response = Response(status=302) + user = authenticate(username=user_data['email'], password='google') + if user is not None: + if user.is_active: + data = get_tokens_for_user(user) + response.set_cookie( + key='access', + value=data["access"], + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(minutes=30), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=True, + samesite='Lax' + ) + + response.set_cookie( + key='refresh', + value=data["refresh"], + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=True, + samesite='Lax' + ) + + response.set_cookie( + key='LoggedIn', + value=True, + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=False, + samesite='Lax' + ) + + response.set_cookie( + key='isProfileComplete', + value=user.profile_complete, + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=False, + samesite='Lax' + ) + + response.set_cookie( + key='isGoogle', + value=user.is_google, + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=False, + samesite='Lax' + ) + if user.profile_complete: + response['Location'] = f'{settings.BASE_FRONTEND_URL}' # homepage url + + else: + response['Location'] = f'{settings.BASE_FRONTEND_URL}/profile' # complete profile url + + response.data = {"Success": "Registration successfull", "data": data} + return response + else: + params = urlencode({'Error': "This account is not active!!"}) + return redirect(f'{login_url}?{params}') + else: + params = urlencode({'Error': "Invalid username or password!!"}) + return redirect(f'{login_url}?{params}') + + +class GoogleRegisterViewApp(APIView): + def post(self, request, format=None): + secret = settings.APP_SECRET + incoming_secret = request.headers.get('X-App') + + if secret != incoming_secret: + return Response(data={"message": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + data = request.data + + if BlacklistedEmail.objects.filter(email=data.get("email")).exists(): + return Response(data={"message": "This email is blacklisted"}, status=status.HTTP_403_FORBIDDEN) + + try: + user = User.objects.create( + first_name=data.get('first_name', ''), + last_name=data.get('last_name', ''), + email=data.get('email'), + username=data.get('email'), + google_picture=data.get('picture', ''), + is_google=True + ) + except Exception: + return Response(data={"message": "User Already Exists"}, status=status.HTTP_409_CONFLICT) + + user.set_password('google') + + if data.get('email')[-10:] == "iitj.ac.in": + user.iitj = True + + user.save() + + user = authenticate(username=data.get('email'), password='google') + + if user is None: + return Response(data={"message": "Username or Password Invalid"}, status=status.HTTP_404_NOT_FOUND) + + data = get_tokens_for_user(user) + + res = Response( + data={ + "message": "Registration Successful", + "access": data["access"], + "refresh": data["refresh"] + }, + status=status.HTTP_201_CREATED + ) + + return res + + +class GoogleLoginView(APIView): + class InputSerializer(serializers.Serializer): + code = serializers.CharField(required=False) + error = serializers.CharField(required=False) + + def get(self, request, *args, **kwargs): + input_serializer = self.InputSerializer(data=request.GET) + input_serializer.is_valid(raise_exception=True) + + validated_data = input_serializer.validated_data + + code = validated_data.get('code') + error = validated_data.get('error') + + login_url = f'{settings.BASE_FRONTEND_URL}/login/' + + if error or not code: + params = urlencode({'Error': error}) + return redirect(f'{login_url}?{params}') + + redirect_uri = f'{settings.BASE_BACKEND_URL}/accounts/login/google/' + + try: + access_token = google_get_access_token(code=code, redirect_uri=redirect_uri) + except Exception: + params = urlencode({'Error': "Failed to obtain access token from Google."}) + return redirect(f'{login_url}?{params}') + + try: + user_data = google_get_user_info(access_token=access_token) + except Exception: + params = urlencode({'Error': "Failed to obtain user info from Google."}) + return redirect(f'{login_url}?{params}') + + if BlacklistedEmail.objects.filter(email=user_data["email"]).exists(): + params = urlencode({'Error': "This email is blacklisted"}) + return redirect(f'{login_url}?{params}') + + response = Response(status=302) + + if User.objects.filter(email=user_data['email']).exists(): + if User.objects.get(email=user_data['email']).is_google: + user = authenticate(username=user_data['email'], password='google') + if user is not None: + if user.is_active: + data = get_tokens_for_user(user) + response.set_cookie( + key='access', + value=data["access"], + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(minutes=30), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=True, + samesite='Lax' + ) + + response.set_cookie( + key='refresh', + value=data["refresh"], + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=True, + samesite='Lax' + ) + + response.set_cookie( + key='LoggedIn', + value=True, + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=False, + samesite='Lax' + ) + + response.set_cookie( + key='isProfileComplete', + value=user.profile_complete, + expires=datetime.datetime.strftime(datetime.datetime.now() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=False, + samesite='Lax' + ) + + response.set_cookie( + key='isGoogle', + value=user.is_google, + expires=datetime.datetime.strftime(datetime.datetime.utcnow() + datetime.timedelta(days=15), "%a, %d-%b-%Y %H:%M:%S GMT"), + secure=True, + domain="", + httponly=False, + samesite='Lax' + ) + + if user.profile_complete: + response['Location'] = f'{settings.BASE_FRONTEND_URL}' # homepage url + else: + response['Location'] = f'{settings.BASE_FRONTEND_URL}/profile' # complete profile url + + response.data = {"Success": "Login successfull", "data": data} + return response + else: + params = urlencode({'Error': "This account is not active!!"}) + return redirect(f'{login_url}?{params}') + else: + params = urlencode({'Error': "Please Signup first!!"}) + return redirect(f'{login_url}?{params}') + else: + params = urlencode({'Error': "You signed up using email & password!!"}) + return redirect(f'{login_url}?{params}') + else: + params = urlencode({'Error': "Please Signup first!!"}) + return redirect(f'{login_url}?{params}') + + +class GoogleLoginViewApp(APIView): + def post(self, request, format=None): + secret = settings.APP_SECRET + incoming_secret = request.headers.get('X-App') + + if secret != incoming_secret: + return Response(data={"message": "Forbidden"}, status=status.HTTP_403_FORBIDDEN) + + data = request.data + + if BlacklistedEmail.objects.filter(email=data.get('email')).exists(): + return Response(data={"message": "This email is blacklisted"}, status=status.HTTP_403_FORBIDDEN) + + if User.objects.filter(email=data.get('email')).exists(): + user = User.objects.get(email=data.get('email')) + + if user.is_google: + user = authenticate(username=data.get('email'), password='google') + + if user is None: + return Response(data={"message": "Username or Password Invalid"}, status=status.HTTP_404_NOT_FOUND) + + data = get_tokens_for_user(user) + + res = Response( + data={ + "message": "Logged In Successfully", + "access": data["access"], + "refresh": data["refresh"] + }, + status=status.HTTP_200_OK + ) + + return res + else: + return Response(data={"message": "You signed up using email and password"}, status=status.HTTP_406_NOT_ACCEPTABLE) + else: + return Response(data={"message": "You need to sign up before you can login"}, status=status.HTTP_406_NOT_ACCEPTABLE) + + +class LogoutView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, format=None): + try: + refreshToken = request.COOKIES.get('refresh') + token = RefreshToken(refreshToken) + token.blacklist() + res = Response() + res.delete_cookie('access', domain="") + res.delete_cookie('refresh', domain="") + res.delete_cookie('LoggedIn', domain="") + res.delete_cookie("isProfileComplete", domain="") + res.delete_cookie("isGoogle", domain="") + + return res + except Exception: + raise exceptions.ParseError("Invalid token") diff --git a/backend/settings.py b/backend/settings.py index 0456a24..4d85dd8 100644 --- a/backend/settings.py +++ b/backend/settings.py @@ -1,5 +1,6 @@ +import os from pathlib import Path - +from datetime import timedelta from decouple import config BASE_DIR = Path(__file__).resolve().parent.parent @@ -10,6 +11,8 @@ DEVELOPMENT = config('DEVELOPMENT', default=False, cast=bool) ALLOWED_HOSTS = ['*'] +BASE_FRONTEND_URL = config('BASE_FRONTEND_URL', default="http://localhost:3000") +BASE_BACKEND_URL = config('BASE_BACKEND_URL', default="http://localhost:8000") INSTALLED_APPS = [ 'django.contrib.admin', @@ -19,6 +22,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'rest_framework_simplejwt.token_blacklist', 'import_export', 'accounts', 'events', @@ -43,10 +47,12 @@ ROOT_URLCONF = 'backend.urls' +APP_SECRET = config('APP_SECRET') + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], + 'DIRS': [os.path.join(BASE_DIR, 'frontend')], 'APP_DIRS': True, 'OPTIONS': { 'context_processors': [ @@ -109,6 +115,15 @@ }, ] +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' + +DEFAULT_FROM_EMAIL = '"Festival, IIT Jodhpur" ' +EMAIL_HOST = config("EMAIL_HOST") +EMAIL_PORT = config("EMAIL_PORT") +EMAIL_HOST_USER = config("EMAIL_HOST_USER") +EMAIL_HOST_PASSWORD = config("EMAIL_HOST_PASSWORD") +EMAIL_USE_TLS = True + LANGUAGE_CODE = 'en-us' TIME_ZONE = 'Asia/Kolkata' @@ -119,12 +134,32 @@ USE_TZ = True +STATIC_ROOT = os.path.join(BASE_DIR, config('STATIC_PATH', default='staticfiles', cast=str)) # type: ignore STATIC_URL = '/static/' DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' DEFAULT_PROFILE_IMAGE_URL = config("DEFAULT_PROFILE_IMAGE_URL") +MEDIA_URL = '/media/' +MEDIA_ROOT = os.path.join(BASE_DIR, 'media') + +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Rest Framework +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'registration.authenticate.CustomAuthentication', + ], +} + +# Simple JWT +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=15), + 'ROTATE_REFRESH_TOKENS': False, +} + # CKEditor CKEDITOR_UPLOAD_PATH = "uploads/" CKEDITOR_RESTRICT_BY_USER = True @@ -169,3 +204,8 @@ ]), }, } + + +# Google OAuth2 settings +GOOGLE_OAUTH2_CLIENT_ID = config('DJANGO_GOOGLE_OAUTH2_CLIENT_ID', cast=str) +GOOGLE_OAUTH2_CLIENT_SECRET = config('DJANGO_GOOGLE_OAUTH2_CLIENT_SECRET', cast=str)