diff --git a/users/__init__.py b/authentication/__init__.py similarity index 100% rename from users/__init__.py rename to authentication/__init__.py diff --git a/authentication/admin.py b/authentication/admin.py new file mode 100644 index 0000000..6d99c8c --- /dev/null +++ b/authentication/admin.py @@ -0,0 +1,9 @@ +from django.contrib import admin +from .models import User + + +class UserAdmin(admin.ModelAdmin): + list_display = ['username', 'email', 'auth_provider', 'created_at'] + + +admin.site.register(User, UserAdmin) \ No newline at end of file diff --git a/authentication/apps.py b/authentication/apps.py new file mode 100644 index 0000000..8bab8df --- /dev/null +++ b/authentication/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AuthenticationConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'authentication' diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..c87daa8 --- /dev/null +++ b/authentication/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.0.3 on 2024-06-05 18:01 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(db_index=True, max_length=255, unique=True)), + ('email', models.EmailField(db_index=True, max_length=255, unique=True)), + ('is_verified', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=True)), + ('is_staff', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('auth_provider', models.CharField(default=None, max_length=255)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + ), + ] diff --git a/users/migrations/__init__.py b/authentication/migrations/__init__.py similarity index 100% rename from users/migrations/__init__.py rename to authentication/migrations/__init__.py diff --git a/authentication/models.py b/authentication/models.py new file mode 100644 index 0000000..a5402a5 --- /dev/null +++ b/authentication/models.py @@ -0,0 +1,60 @@ +from django.contrib.auth.models import ( + AbstractBaseUser, BaseUserManager, PermissionsMixin) + +from django.db import models +from rest_framework_simplejwt.tokens import RefreshToken + + +class UserManager(BaseUserManager): + + def create_user(self, username, email, password=None): + if username is None: + raise TypeError('Users should have a username') + if email is None: + raise TypeError('Users should have a Email') + + user = self.model(username=username, email=self.normalize_email(email)) + user.set_password(password) + user.save() + return user + + def create_superuser(self, username, email, password=None): + if password is None: + raise TypeError('Password should not be none') + + user = self.create_user(username, email, password) + user.is_superuser = True + user.is_staff = True + user.save() + return user + + +AUTH_PROVIDERS = {'google': 'google','email': 'email'} + + +class User(AbstractBaseUser, PermissionsMixin): + username = models.CharField(max_length=255, unique=True, db_index=True) + email = models.EmailField(max_length=255, unique=True, db_index=True) + is_verified = models.BooleanField(default=False) + is_active = models.BooleanField(default=True) + is_staff = models.BooleanField(default=False) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + auth_provider = models.CharField( + max_length=255, blank=False, + null=False, default=AUTH_PROVIDERS.get('email')) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username'] + + objects = UserManager() + + def __str__(self): + return self.email + + def tokens(self): + refresh = RefreshToken.for_user(self) + return { + 'refresh': str(refresh), + 'access': str(refresh.access_token) + } diff --git a/users/tests.py b/authentication/tests.py similarity index 100% rename from users/tests.py rename to authentication/tests.py diff --git a/users/views.py b/authentication/views.py similarity index 100% rename from users/views.py rename to authentication/views.py diff --git a/clearpath_home/settings.py b/clearpath_home/settings.py index 4f9c9fe..b327b84 100644 --- a/clearpath_home/settings.py +++ b/clearpath_home/settings.py @@ -11,7 +11,7 @@ """ from pathlib import Path - +import datetime # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -26,11 +26,17 @@ # Use the environment variables SECRET_KEY = str(os.getenv('SECRET_KEY')) -SOCIAL_AUTH_GITHUB_KEY = str(os.getenv('GITHUB_KEY')) +SOCIAL_SECRET = str(os.getenv('SOCIAL_SECRET')) SOCIAL_AUTH_GITHUB_SECRET = str(os.getenv('GITHUB_SECRET')) SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = str(os.getenv('GOOGLE_KEY')) SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = str(os.getenv('GOOGLE_SECRET')) +GOOGLE_CLIENT_ID = os.getenv('GOOGLE_CLIENT_ID') + + +AUTH_USER_MODEL = 'authentication.User' + +CORS_ALLOW_ALL_ORIGINS = True # SECURITY WARNING: don't run with debug turned on in production! DEBUG = True @@ -46,6 +52,10 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt.token_blacklist', + 'authentication', + 'corsheaders', ] MIDDLEWARE = [ @@ -60,6 +70,30 @@ ROOT_URLCONF = 'clearpath_home.urls' +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + + ) +} + +# JWT Settings +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': datetime.timedelta(minutes=1), + 'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=1), +} + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + TEMPLATES = [ { 'BACKEND': 'django.template.backends.django.DjangoTemplates', diff --git a/clearpath_home/urls.py b/clearpath_home/urls.py index 2ceef0e..afbb6c3 100644 --- a/clearpath_home/urls.py +++ b/clearpath_home/urls.py @@ -15,8 +15,9 @@ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path('admin/', admin.site.urls), + path('google_auth/', include(('google_auth.urls', 'google_auth'),namespace="google_auth")), ] diff --git a/google_auth/__init__.py b/google_auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/admin.py b/google_auth/admin.py similarity index 100% rename from users/admin.py rename to google_auth/admin.py diff --git a/users/apps.py b/google_auth/apps.py similarity index 60% rename from users/apps.py rename to google_auth/apps.py index 72b1401..3eb01b8 100644 --- a/users/apps.py +++ b/google_auth/apps.py @@ -1,6 +1,6 @@ from django.apps import AppConfig -class UsersConfig(AppConfig): +class GoogleAuthConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'users' + name = 'google_auth' diff --git a/google_auth/google.py b/google_auth/google.py new file mode 100644 index 0000000..6864115 --- /dev/null +++ b/google_auth/google.py @@ -0,0 +1,35 @@ +from google.auth.transport import requests +from google.oauth2 import id_token +from rest_framework import serializers, status + +class Google: + """Google class to fetch the user info and return it""" + + @staticmethod + def validate(auth_token): + """ + Validate method queries the Google OAuth2 API to fetch the user info + """ + try: + idinfo = id_token.verify_oauth2_token(auth_token, requests.Request(), clock_skew_in_seconds=5) + + # Acceptable issuer URLs + valid_issuers = [ + 'https://accounts.google.com', + 'accounts.google.com' + ] + + # matching value issuers + if idinfo['iss'] not in valid_issuers: + raise serializers.ValidationError('Invalid token issuer.') + + print(f"Token validated successfully: {idinfo}") + return idinfo + + except ValueError as ve: + raise serializers.ValidationError('The token is either invalid or has expired.') + + except Exception as e: + # error handling + print(f"Token validation error: {e}") + raise serializers.ValidationError('An error occurred during token validation.') diff --git a/google_auth/migrations/__init__.py b/google_auth/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/models.py b/google_auth/models.py similarity index 100% rename from users/models.py rename to google_auth/models.py diff --git a/google_auth/register.py b/google_auth/register.py new file mode 100644 index 0000000..3450940 --- /dev/null +++ b/google_auth/register.py @@ -0,0 +1,40 @@ +from django.contrib.auth import authenticate +from authentication.models import User +import os +from rest_framework.exceptions import AuthenticationFailed + + +def register_social_user(provider, user_id, email, name): + filtered_user_by_email = User.objects.filter(email=email) + + if filtered_user_by_email.exists(): + print(f"User with email {email} exists.") + if provider == filtered_user_by_email[0].auth_provider: + registered_user = authenticate(email=email, password=os.environ.get('SOCIAL_SECRET')) + print(f"Authenticated existing user: {registered_user.username}") + return { + 'username': registered_user.username, + 'email': registered_user.email, + 'tokens': registered_user.tokens() + } + else: + print(f"Authentication failed: Existing user uses a different provider.") + raise AuthenticationFailed(detail=f'Please continue your login using {filtered_user_by_email[0].auth_provider}') + else: + print(f"Creating new user with email {email}") + user = { + 'username': email, + 'email': email, + 'password': os.environ.get('SOCIAL_SECRET') + } + user = User.objects.create_user(**user) + user.is_verified = True + user.auth_provider = provider + user.save() + new_user = authenticate(email=email, password=os.environ.get('SOCIAL_SECRET')) + print(f"Created and authenticated new user: {new_user.username}") + return { + 'email': new_user.email, + 'username': new_user.username, + 'tokens': new_user.tokens() + } diff --git a/google_auth/serializers.py b/google_auth/serializers.py new file mode 100644 index 0000000..22b4347 --- /dev/null +++ b/google_auth/serializers.py @@ -0,0 +1,29 @@ +from rest_framework import serializers +from .google import Google +from .register import register_social_user +import os +from rest_framework.exceptions import AuthenticationFailed + +class GoogleSocialAuthSerializer(serializers.Serializer): + auth_token = serializers.CharField() + + def validate_auth_token(self, auth_token): + user_data = Google.validate(auth_token) + try: + user_data['sub'] + except KeyError: + print('KeyError: sub not found in user_data') + raise serializers.ValidationError('The token is invalid or expired. Please login again.') + + if user_data['aud'] != os.environ.get('GOOGLE_CLIENT_ID'): + print(f"Invalid client ID: {user_data['aud']} != {os.environ.get('GOOGLE_CLIENT_ID')}") + raise AuthenticationFailed('Invalid client ID.') + + user_id = user_data['sub'] + email = user_data['email'] + name = user_data['name'] + provider = 'google' + + print(f"User data: user_id={user_id}, email={email}, name={name}, provider={provider}") + + return register_social_user(provider=provider, user_id=user_id, email=email, name=name) diff --git a/google_auth/tests.py b/google_auth/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/google_auth/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/google_auth/urls.py b/google_auth/urls.py new file mode 100644 index 0000000..a7f3f6b --- /dev/null +++ b/google_auth/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import GoogleSocialAuthView + +urlpatterns = [ + path('google/', GoogleSocialAuthView.as_view()), + +] diff --git a/google_auth/views.py b/google_auth/views.py new file mode 100644 index 0000000..3d18b30 --- /dev/null +++ b/google_auth/views.py @@ -0,0 +1,21 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.generics import GenericAPIView +from .serializers import GoogleSocialAuthSerializer + +class GoogleSocialAuthView(GenericAPIView): + serializer_class = GoogleSocialAuthSerializer + + def post(self, request): + """ + POST with "auth_token" + Send an idtoken as from google to get user information + """ + print('Request data:', request.data) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + data = serializer.validated_data['auth_token'] + print('Validated auth_token:', data) + return Response(data, status=status.HTTP_200_OK) + +