diff --git a/.gitignore b/.gitignore index 0577ca4..884301c 100644 --- a/.gitignore +++ b/.gitignore @@ -174,7 +174,7 @@ cython_debug/ # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore # and can be added to the global gitignore or merged into this file. For a more nuclear # option (not recommended) you can uncomment the following to ignore the entire idea folder. -#.idea/ +.idea/ # Abstra # Abstra is an AI-powered process automation framework. diff --git a/auth/models.py b/auth/models.py deleted file mode 100644 index 71a8362..0000000 --- a/auth/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/auth/tests.py b/auth/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/auth/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/auth/urls.py b/auth/urls.py deleted file mode 100644 index b2b9c09..0000000 --- a/auth/urls.py +++ /dev/null @@ -1,6 +0,0 @@ -from django.urls import path - -urlpatterns = [ - path('login/', login_view.as_view(), name='login'), - path('register/', register_view.as_view(), name='register') -] \ No newline at end of file diff --git a/auth/views.py b/auth/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/auth/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/requirements.txt b/requirements.txt index a91ddfc..b614963 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,8 @@ asgiref==3.9.1 Django==5.2.5 +django-cors-headers==4.7.0 +django-debug-toolbar==6.0.0 djangorestframework==3.16.1 +djangorestframework_simplejwt==5.5.1 +PyJWT==2.10.1 sqlparse==0.5.3 diff --git a/skill_forge/settings.py b/skill_forge/settings.py index c2c801a..7cab9b5 100644 --- a/skill_forge/settings.py +++ b/skill_forge/settings.py @@ -11,6 +11,7 @@ """ from pathlib import Path +from datetime import timedelta # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -37,10 +38,47 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', - 'auth', + 'corsheaders', + 'debug_toolbar', 'rest_framework', + 'userauth', ] +CORS_ALLOWED_ORIGINS = [ + 'http://localhost:5173', +] + +CORS_ALLOW_HEADERS = [ + 'accept', + 'accept-encoding', + 'authorization', + 'content-type', + 'dnt', + 'origin', + 'user-agent', + 'x-csrftoken', + 'x-requested-with', +] + +CORS_ALLOW_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'] + +CORS_ALLOW_CREDENTIALS = True + +AUTH_USER_MODEL = 'userauth.AppUser' +# REST_FRAMEWORK = { +# 'DEFAULT_AUTHENTICATION_CLASSES': ( +# 'rest_framework_simplejwt.authentication.JWTAuthentication', +# ), +# } +# +# SIMPLE_JWT = { +# 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=30), +# 'REFRESH_TOKEN_LIFETIME': timedelta(days=7), +# 'ROTATE_REFRESH_TOKENS': True, # Enable refresh token rotation +# 'BLACKLIST_AFTER_ROTATION': True, # Blacklist old refresh tokens after rotation +# 'AUTH_HEADER_TYPES': ('Bearer',), +# } + MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -49,6 +87,12 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'debug_toolbar.middleware.DebugToolbarMiddleware', + 'corsheaders.middleware.CorsMiddleware', +] + +INTERNAL_IPS = [ + '127.0.0.1', ] ROOT_URLCONF = 'skill_forge.urls' @@ -98,6 +142,9 @@ { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, + { + "NAME": "userauth.validators.CustomPasswordValidator", + }, ] diff --git a/skill_forge/urls.py b/skill_forge/urls.py index 2c8fb01..663bfbc 100644 --- a/skill_forge/urls.py +++ b/skill_forge/urls.py @@ -17,7 +17,13 @@ from django.contrib import admin from django.urls import path, include +from skill_forge import settings + urlpatterns = [ path('admin/', admin.site.urls), - path('', include('auth.urls')) + path('', include('userauth.urls')) ] + +if settings.DEBUG: + import debug_toolbar + urlpatterns = [path('__debug__/', include(debug_toolbar.urls))] + urlpatterns diff --git a/auth/__init__.py b/userauth/__init__.py similarity index 100% rename from auth/__init__.py rename to userauth/__init__.py diff --git a/auth/admin.py b/userauth/admin.py similarity index 100% rename from auth/admin.py rename to userauth/admin.py diff --git a/auth/apps.py b/userauth/apps.py similarity index 84% rename from auth/apps.py rename to userauth/apps.py index 836fe02..97dca0d 100644 --- a/auth/apps.py +++ b/userauth/apps.py @@ -3,4 +3,4 @@ class AuthConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' - name = 'auth' + name = 'userauth' diff --git a/userauth/models.py b/userauth/models.py new file mode 100644 index 0000000..5d1f8d8 --- /dev/null +++ b/userauth/models.py @@ -0,0 +1,12 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models +from django.utils.translation import gettext_lazy as _ + +class AppUser(AbstractUser): + email = models.EmailField(_('email address'), unique=True) + + USERNAME_FIELD = 'email' + REQUIRED_FIELDS = ['username'] + + class Meta: + db_table = "app_user" \ No newline at end of file diff --git a/userauth/serializers.py b/userauth/serializers.py new file mode 100644 index 0000000..cc1faa8 --- /dev/null +++ b/userauth/serializers.py @@ -0,0 +1,40 @@ +from django.contrib.auth.password_validation import validate_password +from rest_framework import serializers +from userauth.models import AppUser + +class AppUserSerializer(serializers.ModelSerializer): + """ + Serializer for registering a new user. + + Validates and creates a new user using Django's built-in User model. + Password is write-only and securely hashed before saving. + """ + + password = serializers.CharField(write_only=True, required=True) + password2 = serializers.CharField(write_only=True, required=True) + + class Meta: + model = AppUser + fields = ['id', 'username', 'password', 'password2', 'email', 'first_name', 'last_name', 'is_staff'] + read_only_fields = ['id', 'is_staff'] + + def validate_password(self, value): + validate_password(value, self.instance) + return value + + def validate(self, attrs): + if attrs['password'] != attrs["password2"]: + raise serializers.ValidationError({"password2": "Passwords do not match."}) + return attrs + + def create(self, validated_data): + user = AppUser( + username=validated_data['username'], + email=validated_data['email'], + first_name=validated_data.get('first_name', ''), + last_name=validated_data.get('last_name', ''), + ) + user.set_password(validated_data['password']) + user.save() + return user + \ No newline at end of file diff --git a/userauth/tests.py b/userauth/tests.py new file mode 100644 index 0000000..91bb9b9 --- /dev/null +++ b/userauth/tests.py @@ -0,0 +1,62 @@ +from django.urls import reverse +from rest_framework.test import APITestCase +from rest_framework import status +from .models import AppUser + +class RegistrationTestCase(APITestCase): + """ + Test suite for AppUser registration endpoint. + """ + + def setUp(self): + """ + Set up test data and endpoint URL. + """ + self.register_url = reverse('register') + self.user_data = { + 'email': 'test@example.com', + 'username': 'testuser', + 'password': 'Password13579!!!', + 'password2': 'Password13579!!!', + 'first_name': 'Test', + 'last_name': 'User' + } + + def test_user_registration(self): + """ + Test successful registration of a new AppUser. + """ + response = self.client.post(self.register_url, self.user_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(AppUser.objects.count(), 1) + self.assertEqual(AppUser.objects.get().email, 'test@example.com') + + def test_user_missing_email(self): + """ + Test registration fails when email is missing. + """ + data = self.user_data.copy() + data.pop('email') + response = self.client.post(self.register_url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('email', response.data) + + def test_user_missing_username(self): + """ + Test registration fails when username is missing. + """ + data = self.user_data.copy() + data.pop('username') + response = self.client.post(self.register_url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('username', response.data) + + def test_user_missing_password(self): + """ + Test registration fails when password is missing. + """ + data = self.user_data.copy() + data.pop('password') + response = self.client.post(self.register_url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertIn('password', response.data) \ No newline at end of file diff --git a/userauth/urls.py b/userauth/urls.py new file mode 100644 index 0000000..88a6cec --- /dev/null +++ b/userauth/urls.py @@ -0,0 +1,12 @@ +from django.urls import path +from .views import RegisterView +# from rest_framework_simplejwt.views import ( +# TokenObtainPairView, +# TokenRefreshView, +# ) + +urlpatterns = [ + path('register', RegisterView.as_view(), name='register'), + # path('login/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + # path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), +] \ No newline at end of file diff --git a/userauth/validators.py b/userauth/validators.py new file mode 100644 index 0000000..42bb485 --- /dev/null +++ b/userauth/validators.py @@ -0,0 +1,18 @@ +import re +from django.core.exceptions import ValidationError +from django.utils.translation import gettext as _ + +class CustomPasswordValidator: + def validate(self, password, user=None): + + errors = [] + if len(password) < 8: + errors.append(_("Password must be at least 8 characters long.")) + if not re.search(r"[A-Z]", password): + errors.append(_("Password must contain at least one uppercase letter.")) + if not re.search(r"[a-z]", password): + errors.append(_("Password must contain at least one lowercase letter.")) + if not re.search(r"\d", password): + errors.append(_("Password must contain at least one digit.")) + if errors: + raise ValidationError(errors) diff --git a/userauth/views.py b/userauth/views.py new file mode 100644 index 0000000..2a9d98d --- /dev/null +++ b/userauth/views.py @@ -0,0 +1,15 @@ +from rest_framework import generics +from .models import AppUser +from .serializers import AppUserSerializer + + +class RegisterView(generics.CreateAPIView): + """ + API view for user registration. + + Accepts POST requests with username, email, and password. + Returns the created user data (excluding password). + """ + + queryset = AppUser.objects.all() + serializer_class = AppUserSerializer