From 83f31c08b86765c43ef2d24ef69422aacb844953 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 13 Feb 2025 10:46:10 +0000 Subject: [PATCH 1/8] Fix issue #11: Implement User Authentication API Endpoints --- auth_system/settings.py | 39 +++++++ auth_system/urls.py | 3 +- poetry.lock | 29 +++++- pyproject.toml | 2 + users/serializers.py | 64 ++++++++++++ users/tests/test_auth.py | 112 ++++++++++++++++++++ users/urls.py | 14 +++ users/views.py | 218 ++++++++++++++++++++++++++++++++++++++- 8 files changed, 477 insertions(+), 4 deletions(-) create mode 100644 users/serializers.py create mode 100644 users/tests/test_auth.py create mode 100644 users/urls.py diff --git a/auth_system/settings.py b/auth_system/settings.py index 92a42ab..15a21d0 100644 --- a/auth_system/settings.py +++ b/auth_system/settings.py @@ -41,9 +41,48 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', + 'rest_framework_simplejwt.token_blacklist', 'users', ] +# REST Framework settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': ( + 'rest_framework_simplejwt.authentication.JWTAuthentication', + ), + 'DEFAULT_PERMISSION_CLASSES': ( + 'rest_framework.permissions.IsAuthenticated', + ), +} + +# JWT settings +from datetime import timedelta +SIMPLE_JWT = { + 'ACCESS_TOKEN_LIFETIME': timedelta(minutes=15), + 'REFRESH_TOKEN_LIFETIME': timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, + 'UPDATE_LAST_LOGIN': True, + 'ALGORITHM': 'HS256', + 'SIGNING_KEY': SECRET_KEY, + 'VERIFYING_KEY': None, + 'AUTH_HEADER_TYPES': ('Bearer',), + 'USER_ID_FIELD': 'id', + 'USER_ID_CLAIM': 'user_id', + 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',), + 'TOKEN_TYPE_CLAIM': 'token_type', +} + +# Email settings +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = os.getenv('EMAIL_HOST', 'smtp.gmail.com') +EMAIL_PORT = int(os.getenv('EMAIL_PORT', 587)) +EMAIL_USE_TLS = os.getenv('EMAIL_USE_TLS', 'True').lower() == 'true' +EMAIL_HOST_USER = os.getenv('EMAIL_HOST_USER', '') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') +DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@openhands.com') + # Custom User model AUTH_USER_MODEL = 'users.User' diff --git a/auth_system/urls.py b/auth_system/urls.py index 2c2cc56..af1abbd 100644 --- a/auth_system/urls.py +++ b/auth_system/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('api/', include('users.urls')), ] diff --git a/poetry.lock b/poetry.lock index 4bfc6ca..b5da12f 100644 --- a/poetry.lock +++ b/poetry.lock @@ -188,6 +188,18 @@ tzdata = {version = "*", markers = "sys_platform == \"win32\""} argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] +[[package]] +name = "django-ratelimit" +version = "4.1.0" +description = "Cache-based rate-limiting for Django." +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "django-ratelimit-4.1.0.tar.gz", hash = "sha256:555943b283045b917ad59f196829530d63be2a39adb72788d985b90c81ba808b"}, + {file = "django_ratelimit-4.1.0-py2.py3-none-any.whl", hash = "sha256:d047a31cf94d83ef1465d7543ca66c6fc16695559b5f8d814d1b51df15110b92"}, +] + [[package]] name = "djangorestframework" version = "3.15.2" @@ -397,6 +409,21 @@ dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pyte docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"] tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +[[package]] +name = "pyotp" +version = "2.9.0" +description = "Python One Time Password Library" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "pyotp-2.9.0-py3-none-any.whl", hash = "sha256:81c2e5865b8ac55e825b0358e496e1d9387c811e85bb40e71a3b29b288963612"}, + {file = "pyotp-2.9.0.tar.gz", hash = "sha256:346b6642e0dbdde3b4ff5a930b664ca82abfa116356ed48cc42c7d6590d36f63"}, +] + +[package.extras] +test = ["coverage", "mypy", "ruff", "wheel"] + [[package]] name = "pytest" version = "7.4.4" @@ -484,4 +511,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "0d4e425dbac539bbfbf126b455f80c88527120767143419f9eee7d1bd5300398" +content-hash = "2f6fb2cf091971505cd40f5d8e6692a18a3caef39a737f078f33a811189cbfc8" diff --git a/pyproject.toml b/pyproject.toml index aad5f8f..c9ca1cb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,8 @@ gunicorn = "^21.2.0" argon2-cffi = "^23.1.0" djangorestframework = "^3.14.0" djangorestframework-simplejwt = "^5.3.1" +pyotp = "^2.9.0" +django-ratelimit = "^4.1.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.4" diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..6c0ea78 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,64 @@ +from rest_framework import serializers +from django.contrib.auth import get_user_model +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError +from .models import MFADevice + +User = get_user_model() + +class UserRegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, required=True) + password_confirm = serializers.CharField(write_only=True, required=True) + + class Meta: + model = User + fields = ('email', 'username', 'password', 'password_confirm') + + def validate(self, data): + if data['password'] != data['password_confirm']: + raise serializers.ValidationError("Passwords do not match.") + try: + validate_password(data['password']) + except ValidationError as e: + raise serializers.ValidationError(str(e)) + return data + + def create(self, validated_data): + validated_data.pop('password_confirm') + user = User.objects.create_user( + email=validated_data['email'], + username=validated_data['username'], + password=validated_data['password'] + ) + return user + +class UserLoginSerializer(serializers.Serializer): + email = serializers.EmailField() + password = serializers.CharField() + mfa_code = serializers.CharField(required=False) + +class PasswordResetRequestSerializer(serializers.Serializer): + email = serializers.EmailField() + +class PasswordResetConfirmSerializer(serializers.Serializer): + token = serializers.CharField() + password = serializers.CharField(write_only=True) + password_confirm = serializers.CharField(write_only=True) + + def validate(self, data): + if data['password'] != data['password_confirm']: + raise serializers.ValidationError("Passwords do not match.") + try: + validate_password(data['password']) + except ValidationError as e: + raise serializers.ValidationError(str(e)) + return data + +class MFAEnableSerializer(serializers.ModelSerializer): + class Meta: + model = MFADevice + fields = ('secret_key',) + read_only_fields = ('secret_key',) + +class MFAVerifySerializer(serializers.Serializer): + code = serializers.CharField() diff --git a/users/tests/test_auth.py b/users/tests/test_auth.py new file mode 100644 index 0000000..07d75a9 --- /dev/null +++ b/users/tests/test_auth.py @@ -0,0 +1,112 @@ +import pyotp +from django.test import TestCase +from django.urls import reverse +from django.contrib.auth import get_user_model +from rest_framework.test import APIClient +from rest_framework import status +from ..models import MFADevice + +User = get_user_model() + +class AuthenticationTests(TestCase): + def setUp(self): + self.client = APIClient() + self.register_url = reverse('register') + self.login_url = reverse('login') + self.logout_url = reverse('logout') + self.password_reset_url = reverse('password_reset') + self.password_reset_confirm_url = reverse('password_reset_confirm') + self.mfa_enable_url = reverse('mfa_enable') + self.mfa_verify_url = reverse('mfa_verify') + + self.user_data = { + 'email': 'test@example.com', + 'username': 'testuser', + 'password': 'TestPass123!', + 'password_confirm': 'TestPass123!' + } + + def test_user_registration(self): + response = self.client.post(self.register_url, self.user_data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertTrue(User.objects.filter(email=self.user_data['email']).exists()) + + def test_user_login(self): + # Create user + User.objects.create_user( + email=self.user_data['email'], + username=self.user_data['username'], + password=self.user_data['password'] + ) + + # Login + response = self.client.post(self.login_url, { + 'email': self.user_data['email'], + 'password': self.user_data['password'] + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access', response.data) + self.assertIn('refresh', response.data) + + def test_user_logout(self): + # Create and login user + user = User.objects.create_user( + email=self.user_data['email'], + username=self.user_data['username'], + password=self.user_data['password'] + ) + response = self.client.post(self.login_url, { + 'email': self.user_data['email'], + 'password': self.user_data['password'] + }) + refresh_token = response.data['refresh'] + + # Logout + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {response.data["access"]}') + response = self.client.post(self.logout_url, {'refresh': refresh_token}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_password_reset_request(self): + # Create user + User.objects.create_user( + email=self.user_data['email'], + username=self.user_data['username'], + password=self.user_data['password'] + ) + + response = self.client.post(self.password_reset_url, { + 'email': self.user_data['email'] + }) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_mfa_enable_and_verify(self): + # Create and login user + user = User.objects.create_user( + email=self.user_data['email'], + username=self.user_data['username'], + password=self.user_data['password'] + ) + response = self.client.post(self.login_url, { + 'email': self.user_data['email'], + 'password': self.user_data['password'] + }) + self.client.credentials(HTTP_AUTHORIZATION=f'Bearer {response.data["access"]}') + + # Enable MFA + response = self.client.post(self.mfa_enable_url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('secret', response.data) + self.assertIn('qr_code_uri', response.data) + + # Get MFA device + mfa_device = MFADevice.objects.get(user=user) + totp = pyotp.TOTP(mfa_device.secret_key) + code = totp.now() + + # Verify MFA + response = self.client.post(self.mfa_verify_url, {'code': code}) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + # Check user has MFA enabled + user.refresh_from_db() + self.assertTrue(user.two_factor_enabled) diff --git a/users/urls.py b/users/urls.py new file mode 100644 index 0000000..e96d97d --- /dev/null +++ b/users/urls.py @@ -0,0 +1,14 @@ +from django.urls import path +from rest_framework_simplejwt.views import TokenRefreshView +from . import views + +urlpatterns = [ + path('auth/register/', views.RegisterView.as_view(), name='register'), + path('auth/login/', views.LoginView.as_view(), name='login'), + path('auth/logout/', views.LogoutView.as_view(), name='logout'), + path('auth/token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), + path('auth/password-reset/', views.PasswordResetView.as_view(), name='password_reset'), + path('auth/password-reset-confirm/', views.PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('auth/mfa/enable/', views.MFAEnableView.as_view(), name='mfa_enable'), + path('auth/mfa/verify/', views.MFAVerifyView.as_view(), name='mfa_verify'), +] diff --git a/users/views.py b/users/views.py index 91ea44a..b5804d3 100644 --- a/users/views.py +++ b/users/views.py @@ -1,3 +1,217 @@ -from django.shortcuts import render +import pyotp +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.mail import send_mail +from django.utils.crypto import get_random_string +from django.utils import timezone +from rest_framework import status, permissions +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from django_ratelimit.decorators import ratelimit +from django.utils.decorators import method_decorator +from .models import Session, MFADevice +from .serializers import ( + UserRegistrationSerializer, + UserLoginSerializer, + PasswordResetRequestSerializer, + PasswordResetConfirmSerializer, + MFAEnableSerializer, + MFAVerifySerializer, +) -# Create your views here. +User = get_user_model() + +class RegisterView(APIView): + @method_decorator(ratelimit(key='ip', rate='5/m', method=['POST'])) + def post(self, request): + serializer = UserRegistrationSerializer(data=request.data) + if serializer.is_valid(): + user = serializer.save() + return Response( + {"message": "User registered successfully"}, + status=status.HTTP_201_CREATED + ) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class LoginView(APIView): + @method_decorator(ratelimit(key='ip', rate='5/m', method=['POST'])) + def post(self, request): + serializer = UserLoginSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + email = serializer.validated_data['email'] + password = serializer.validated_data['password'] + mfa_code = serializer.validated_data.get('mfa_code') + + try: + user = User.objects.get(email=email) + except User.DoesNotExist: + return Response( + {"error": "Invalid credentials"}, + status=status.HTTP_401_UNAUTHORIZED + ) + + if not user.check_password(password): + return Response( + {"error": "Invalid credentials"}, + status=status.HTTP_401_UNAUTHORIZED + ) + + if user.two_factor_enabled: + if not mfa_code: + return Response( + {"error": "MFA code required"}, + status=status.HTTP_400_BAD_REQUEST + ) + + mfa_device = MFADevice.objects.get(user=user) + totp = pyotp.TOTP(mfa_device.secret_key) + if not totp.verify(mfa_code): + return Response( + {"error": "Invalid MFA code"}, + status=status.HTTP_401_UNAUTHORIZED + ) + + refresh = RefreshToken.for_user(user) + access_token = str(refresh.access_token) + + # Create session + session = Session.objects.create( + user=user, + token=str(refresh), + expires_at=timezone.now() + refresh.lifetime + ) + + return Response({ + 'access': access_token, + 'refresh': str(refresh) + }) + +class LogoutView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + try: + refresh_token = request.data["refresh"] + token = RefreshToken(refresh_token) + token.blacklist() + + # Invalidate session + Session.objects.filter(token=refresh_token).delete() + + return Response( + {"message": "Successfully logged out"}, + status=status.HTTP_200_OK + ) + except Exception: + return Response( + {"error": "Invalid token"}, + status=status.HTTP_400_BAD_REQUEST + ) + +class PasswordResetView(APIView): + @method_decorator(ratelimit(key='ip', rate='3/m', method=['POST'])) + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + email = serializer.validated_data['email'] + try: + user = User.objects.get(email=email) + token = get_random_string(64) + user.set_password(token) + user.save() + + # Send reset email + send_mail( + 'Password Reset', + f'Your password reset token is: {token}', + settings.DEFAULT_FROM_EMAIL, + [email], + fail_silently=False, + ) + + return Response( + {"message": "Password reset instructions sent"}, + status=status.HTTP_200_OK + ) + except User.DoesNotExist: + return Response( + {"message": "Password reset instructions sent"}, + status=status.HTTP_200_OK + ) + +class PasswordResetConfirmView(APIView): + def post(self, request): + serializer = PasswordResetConfirmSerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + token = serializer.validated_data['token'] + password = serializer.validated_data['password'] + + try: + user = User.objects.get(password=token) + user.set_password(password) + user.save() + return Response( + {"message": "Password reset successful"}, + status=status.HTTP_200_OK + ) + except User.DoesNotExist: + return Response( + {"error": "Invalid token"}, + status=status.HTTP_400_BAD_REQUEST + ) + +class MFAEnableView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + if hasattr(request.user, 'mfa_device'): + return Response( + {"error": "MFA is already enabled"}, + status=status.HTTP_400_BAD_REQUEST + ) + + secret = pyotp.random_base32() + mfa_device = MFADevice.objects.create( + user=request.user, + secret_key=secret + ) + + totp = pyotp.TOTP(secret) + provisioning_uri = totp.provisioning_uri( + request.user.email, + issuer_name="OpenHands Auth" + ) + + return Response({ + "secret": secret, + "qr_code_uri": provisioning_uri + }) + +class MFAVerifyView(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + serializer = MFAVerifySerializer(data=request.data) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + code = serializer.validated_data['code'] + mfa_device = request.user.mfa_device + + totp = pyotp.TOTP(mfa_device.secret_key) + if totp.verify(code): + request.user.two_factor_enabled = True + request.user.save() + return Response({"message": "MFA enabled successfully"}) + + return Response( + {"error": "Invalid MFA code"}, + status=status.HTTP_400_BAD_REQUEST + ) From 7a39b6a5cb4e5ed0b19b11f68a915e11c9632a1e Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 13 Feb 2025 14:44:09 +0000 Subject: [PATCH 2/8] Fix pr #13: Fix issue #11: Implement User Authentication API Endpoints --- users/views.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/users/views.py b/users/views.py index b5804d3..5be72e0 100644 --- a/users/views.py +++ b/users/views.py @@ -23,6 +23,8 @@ User = get_user_model() class RegisterView(APIView): + permission_classes = [permissions.AllowAny] + @method_decorator(ratelimit(key='ip', rate='5/m', method=['POST'])) def post(self, request): serializer = UserRegistrationSerializer(data=request.data) @@ -35,6 +37,8 @@ def post(self, request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class LoginView(APIView): + permission_classes = [permissions.AllowAny] + @method_decorator(ratelimit(key='ip', rate='5/m', method=['POST'])) def post(self, request): serializer = UserLoginSerializer(data=request.data) @@ -112,6 +116,8 @@ def post(self, request): ) class PasswordResetView(APIView): + permission_classes = [permissions.AllowAny] + @method_decorator(ratelimit(key='ip', rate='3/m', method=['POST'])) def post(self, request): serializer = PasswordResetRequestSerializer(data=request.data) @@ -145,6 +151,8 @@ def post(self, request): ) class PasswordResetConfirmView(APIView): + permission_classes = [permissions.AllowAny] + def post(self, request): serializer = PasswordResetConfirmSerializer(data=request.data) if not serializer.is_valid(): From b8acdec590a043c7a170f18bbdc139cc4f8db7ae Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 13 Feb 2025 14:53:34 +0000 Subject: [PATCH 3/8] Fix pr #13: Fix issue #11: Implement User Authentication API Endpoints --- users/tests/{ => views}/test_auth.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename users/tests/{ => views}/test_auth.py (100%) diff --git a/users/tests/test_auth.py b/users/tests/views/test_auth.py similarity index 100% rename from users/tests/test_auth.py rename to users/tests/views/test_auth.py From decbc331bc1a560695e91a1d6c610544abf05536 Mon Sep 17 00:00:00 2001 From: cld-vasconcelos Date: Thu, 13 Feb 2025 14:57:11 +0000 Subject: [PATCH 4/8] Add __init__.py files --- users/tests/views/__init__.py | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 users/tests/views/__init__.py diff --git a/users/tests/views/__init__.py b/users/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 From 34a7d1061d659b095ecf8b72cc53d0e7b0147647 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 13 Feb 2025 15:03:45 +0000 Subject: [PATCH 5/8] Fix pr #13: Fix issue #11: Implement User Authentication API Endpoints --- users/tests/views/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/users/tests/views/test_auth.py b/users/tests/views/test_auth.py index 07d75a9..91542f6 100644 --- a/users/tests/views/test_auth.py +++ b/users/tests/views/test_auth.py @@ -4,7 +4,7 @@ from django.contrib.auth import get_user_model from rest_framework.test import APIClient from rest_framework import status -from ..models import MFADevice +from users.models import MFADevice User = get_user_model() From 53fd0a432cb67c9fda0499a8b4c2ff2b9e2fce01 Mon Sep 17 00:00:00 2001 From: cld-vasconcelos Date: Thu, 13 Feb 2025 15:36:39 +0000 Subject: [PATCH 6/8] Add make create-superuser command --- Makefile | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Makefile b/Makefile index b80cf4b..9622aa5 100644 --- a/Makefile +++ b/Makefile @@ -19,4 +19,7 @@ run: poetry run python manage.py runserver 0.0.0.0:8000 test: - poetry run python manage.py test \ No newline at end of file + poetry run python manage.py test + +create-superuser: + DJANGO_SUPERUSER_PASSWORD=admin poetry run python manage.py createsuperuser --noinput --username=admin --email=admin@example.com \ No newline at end of file From f5fb15fb3bf287f79bd78dd31ea1e0e7091ec9a3 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 13 Feb 2025 15:51:23 +0000 Subject: [PATCH 7/8] Fix pr #13: Fix issue #11: Implement User Authentication API Endpoints --- auth_system/settings.py | 20 +++ auth_system/urls.py | 6 + poetry.lock | 313 +++++++++++++++++++++++++++++++++++++++- pyproject.toml | 1 + 4 files changed, 339 insertions(+), 1 deletion(-) diff --git a/auth_system/settings.py b/auth_system/settings.py index 15a21d0..6a7163b 100644 --- a/auth_system/settings.py +++ b/auth_system/settings.py @@ -43,11 +43,13 @@ 'django.contrib.staticfiles', 'rest_framework', 'rest_framework_simplejwt.token_blacklist', + 'drf_spectacular', 'users', ] # REST Framework settings REST_FRAMEWORK = { + 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', ), @@ -83,6 +85,24 @@ EMAIL_HOST_PASSWORD = os.getenv('EMAIL_HOST_PASSWORD', '') DEFAULT_FROM_EMAIL = os.getenv('DEFAULT_FROM_EMAIL', 'noreply@openhands.com') +# Spectacular settings +SPECTACULAR_SETTINGS = { + 'TITLE': 'Authentication System API', + 'DESCRIPTION': 'API for user authentication and management', + 'VERSION': '1.0.0', + 'SERVE_INCLUDE_SCHEMA': False, + 'COMPONENT_SPLIT_REQUEST': True, + 'SECURITY': [ + { + 'Bearer': { + 'type': 'apiKey', + 'name': 'Authorization', + 'in': 'header', + 'description': 'Enter your JWT token in the format: Bearer ' + } + } + ], +} # Custom User model AUTH_USER_MODEL = 'users.User' diff --git a/auth_system/urls.py b/auth_system/urls.py index af1abbd..da4994f 100644 --- a/auth_system/urls.py +++ b/auth_system/urls.py @@ -16,8 +16,14 @@ """ from django.contrib import admin from django.urls import path, include +from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView urlpatterns = [ path('admin/', admin.site.urls), path('api/', include('users.urls')), + + # API Documentation URLs + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), + path('api/docs/swagger/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + path('api/docs/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'), ] diff --git a/poetry.lock b/poetry.lock index b5da12f..4a9bb20 100644 --- a/poetry.lock +++ b/poetry.lock @@ -74,6 +74,26 @@ files = [ [package.extras] tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] +[[package]] +name = "attrs" +version = "25.1.0" +description = "Classes Without Boilerplate" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "attrs-25.1.0-py3-none-any.whl", hash = "sha256:c75a69e28a550a7e93789579c22aa26b0f5b83b75dc4e08fe092980051e1090a"}, + {file = "attrs-25.1.0.tar.gz", hash = "sha256:1c97078a80c814273a76b2a298a932eb681c87415c11dee0a6921de7f1b02c3e"}, +] + +[package.extras] +benchmark = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-codspeed", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +cov = ["cloudpickle", "coverage[toml] (>=5.3)", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +dev = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pre-commit-uv", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +docs = ["cogapp", "furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier (<24.7)"] +tests = ["cloudpickle", "hypothesis", "mypy (>=1.11.1)", "pympler", "pytest (>=4.3.0)", "pytest-mypy-plugins", "pytest-xdist[psutil]"] +tests-mypy = ["mypy (>=1.11.1)", "pytest-mypy-plugins"] + [[package]] name = "cffi" version = "1.17.1" @@ -240,6 +260,30 @@ lint = ["flake8", "isort", "pep8"] python-jose = ["python-jose (==3.3.0)"] test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"] +[[package]] +name = "drf-spectacular" +version = "0.28.0" +description = "Sane and flexible OpenAPI 3 schema generation for Django REST framework" +optional = false +python-versions = ">=3.7" +groups = ["main"] +files = [ + {file = "drf_spectacular-0.28.0-py3-none-any.whl", hash = "sha256:856e7edf1056e49a4245e87a61e8da4baff46c83dbc25be1da2df77f354c7cb4"}, + {file = "drf_spectacular-0.28.0.tar.gz", hash = "sha256:2c778a47a40ab2f5078a7c42e82baba07397bb35b074ae4680721b2805943061"}, +] + +[package.dependencies] +Django = ">=2.2" +djangorestframework = ">=3.10.3" +inflection = ">=0.3.1" +jsonschema = ">=2.6.0" +PyYAML = ">=5.1" +uritemplate = ">=2.0.0" + +[package.extras] +offline = ["drf-spectacular-sidecar"] +sidecar = ["drf-spectacular-sidecar"] + [[package]] name = "gunicorn" version = "21.2.0" @@ -261,6 +305,18 @@ gevent = ["gevent (>=1.4.0)"] setproctitle = ["setproctitle"] tornado = ["tornado (>=0.2)"] +[[package]] +name = "inflection" +version = "0.5.1" +description = "A port of Ruby on Rails inflector to Python" +optional = false +python-versions = ">=3.5" +groups = ["main"] +files = [ + {file = "inflection-0.5.1-py2.py3-none-any.whl", hash = "sha256:f38b2b640938a4f35ade69ac3d053042959b62a0f1076a5bbaa1b9526605a8a2"}, + {file = "inflection-0.5.1.tar.gz", hash = "sha256:1a29730d366e996aaacffb2f1f1cb9593dc38e2ddd30c91250c6dde09ea9b417"}, +] + [[package]] name = "iniconfig" version = "2.0.0" @@ -273,6 +329,43 @@ files = [ {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, ] +[[package]] +name = "jsonschema" +version = "4.23.0" +description = "An implementation of JSON Schema validation for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566"}, + {file = "jsonschema-4.23.0.tar.gz", hash = "sha256:d71497fef26351a33265337fa77ffeb82423f3ea21283cd9467bb03999266bc4"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +jsonschema-specifications = ">=2023.03.6" +referencing = ">=0.28.4" +rpds-py = ">=0.7.1" + +[package.extras] +format = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3987", "uri-template", "webcolors (>=1.11)"] +format-nongpl = ["fqdn", "idna", "isoduration", "jsonpointer (>1.13)", "rfc3339-validator", "rfc3986-validator (>0.1.0)", "uri-template", "webcolors (>=24.6.0)"] + +[[package]] +name = "jsonschema-specifications" +version = "2024.10.1" +description = "The JSON Schema meta-schemas and vocabularies, exposed as a Registry" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "jsonschema_specifications-2024.10.1-py3-none-any.whl", hash = "sha256:a09a0680616357d9a0ecf05c12ad234479f549239d0f5b55f3deea67475da9bf"}, + {file = "jsonschema_specifications-2024.10.1.tar.gz", hash = "sha256:0f38b83639958ce1152d02a7f062902c41c8fd20d558b0c34344292d417ae272"}, +] + +[package.dependencies] +referencing = ">=0.31.0" + [[package]] name = "packaging" version = "24.2" @@ -479,6 +572,199 @@ files = [ [package.extras] cli = ["click (>=5.0)"] +[[package]] +name = "pyyaml" +version = "6.0.2" +description = "YAML parser and emitter for Python" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, + {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, + {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, + {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, + {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, + {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, + {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, + {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, + {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, + {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, + {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, + {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, + {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, + {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, + {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, + {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, + {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, + {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, + {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, + {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, + {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, + {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, + {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, + {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, + {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, + {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, + {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, + {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, + {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, + {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, + {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, + {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, +] + +[[package]] +name = "referencing" +version = "0.36.2" +description = "JSON Referencing + Python" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "referencing-0.36.2-py3-none-any.whl", hash = "sha256:e8699adbbf8b5c7de96d8ffa0eb5c158b3beafce084968e2ea8bb08c6794dcd0"}, + {file = "referencing-0.36.2.tar.gz", hash = "sha256:df2e89862cd09deabbdba16944cc3f10feb6b3e6f18e902f7cc25609a34775aa"}, +] + +[package.dependencies] +attrs = ">=22.2.0" +rpds-py = ">=0.7.0" +typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} + +[[package]] +name = "rpds-py" +version = "0.22.3" +description = "Python bindings to Rust's persistent data structures (rpds)" +optional = false +python-versions = ">=3.9" +groups = ["main"] +files = [ + {file = "rpds_py-0.22.3-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:6c7b99ca52c2c1752b544e310101b98a659b720b21db00e65edca34483259967"}, + {file = "rpds_py-0.22.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:be2eb3f2495ba669d2a985f9b426c1797b7d48d6963899276d22f23e33d47e37"}, + {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:70eb60b3ae9245ddea20f8a4190bd79c705a22f8028aaf8bbdebe4716c3fab24"}, + {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4041711832360a9b75cfb11b25a6a97c8fb49c07b8bd43d0d02b45d0b499a4ff"}, + {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64607d4cbf1b7e3c3c8a14948b99345eda0e161b852e122c6bb71aab6d1d798c"}, + {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e69b0a0e2537f26d73b4e43ad7bc8c8efb39621639b4434b76a3de50c6966e"}, + {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc27863442d388870c1809a87507727b799c8460573cfbb6dc0eeaef5a11b5ec"}, + {file = "rpds_py-0.22.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e79dd39f1e8c3504be0607e5fc6e86bb60fe3584bec8b782578c3b0fde8d932c"}, + {file = "rpds_py-0.22.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e0fa2d4ec53dc51cf7d3bb22e0aa0143966119f42a0c3e4998293a3dd2856b09"}, + {file = "rpds_py-0.22.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:fda7cb070f442bf80b642cd56483b5548e43d366fe3f39b98e67cce780cded00"}, + {file = "rpds_py-0.22.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cff63a0272fcd259dcc3be1657b07c929c466b067ceb1c20060e8d10af56f5bf"}, + {file = "rpds_py-0.22.3-cp310-cp310-win32.whl", hash = "sha256:9bd7228827ec7bb817089e2eb301d907c0d9827a9e558f22f762bb690b131652"}, + {file = "rpds_py-0.22.3-cp310-cp310-win_amd64.whl", hash = "sha256:9beeb01d8c190d7581a4d59522cd3d4b6887040dcfc744af99aa59fef3e041a8"}, + {file = "rpds_py-0.22.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d20cfb4e099748ea39e6f7b16c91ab057989712d31761d3300d43134e26e165f"}, + {file = "rpds_py-0.22.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:68049202f67380ff9aa52f12e92b1c30115f32e6895cd7198fa2a7961621fc5a"}, + {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb4f868f712b2dd4bcc538b0a0c1f63a2b1d584c925e69a224d759e7070a12d5"}, + {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bc51abd01f08117283c5ebf64844a35144a0843ff7b2983e0648e4d3d9f10dbb"}, + {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0f3cec041684de9a4684b1572fe28c7267410e02450f4561700ca5a3bc6695a2"}, + {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7ef9d9da710be50ff6809fed8f1963fecdfecc8b86656cadfca3bc24289414b0"}, + {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59f4a79c19232a5774aee369a0c296712ad0e77f24e62cad53160312b1c1eaa1"}, + {file = "rpds_py-0.22.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a60bce91f81ddaac922a40bbb571a12c1070cb20ebd6d49c48e0b101d87300d"}, + {file = "rpds_py-0.22.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e89391e6d60251560f0a8f4bd32137b077a80d9b7dbe6d5cab1cd80d2746f648"}, + {file = "rpds_py-0.22.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e3fb866d9932a3d7d0c82da76d816996d1667c44891bd861a0f97ba27e84fc74"}, + {file = "rpds_py-0.22.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1352ae4f7c717ae8cba93421a63373e582d19d55d2ee2cbb184344c82d2ae55a"}, + {file = "rpds_py-0.22.3-cp311-cp311-win32.whl", hash = "sha256:b0b4136a252cadfa1adb705bb81524eee47d9f6aab4f2ee4fa1e9d3cd4581f64"}, + {file = "rpds_py-0.22.3-cp311-cp311-win_amd64.whl", hash = "sha256:8bd7c8cfc0b8247c8799080fbff54e0b9619e17cdfeb0478ba7295d43f635d7c"}, + {file = "rpds_py-0.22.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:27e98004595899949bd7a7b34e91fa7c44d7a97c40fcaf1d874168bb652ec67e"}, + {file = "rpds_py-0.22.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1978d0021e943aae58b9b0b196fb4895a25cc53d3956b8e35e0b7682eefb6d56"}, + {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:655ca44a831ecb238d124e0402d98f6212ac527a0ba6c55ca26f616604e60a45"}, + {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:feea821ee2a9273771bae61194004ee2fc33f8ec7db08117ef9147d4bbcbca8e"}, + {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:22bebe05a9ffc70ebfa127efbc429bc26ec9e9b4ee4d15a740033efda515cf3d"}, + {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3af6e48651c4e0d2d166dc1b033b7042ea3f871504b6805ba5f4fe31581d8d38"}, + {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e67ba3c290821343c192f7eae1d8fd5999ca2dc99994114643e2f2d3e6138b15"}, + {file = "rpds_py-0.22.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:02fbb9c288ae08bcb34fb41d516d5eeb0455ac35b5512d03181d755d80810059"}, + {file = "rpds_py-0.22.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f56a6b404f74ab372da986d240e2e002769a7d7102cc73eb238a4f72eec5284e"}, + {file = "rpds_py-0.22.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0a0461200769ab3b9ab7e513f6013b7a97fdeee41c29b9db343f3c5a8e2b9e61"}, + {file = "rpds_py-0.22.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8633e471c6207a039eff6aa116e35f69f3156b3989ea3e2d755f7bc41754a4a7"}, + {file = "rpds_py-0.22.3-cp312-cp312-win32.whl", hash = "sha256:593eba61ba0c3baae5bc9be2f5232430453fb4432048de28399ca7376de9c627"}, + {file = "rpds_py-0.22.3-cp312-cp312-win_amd64.whl", hash = "sha256:d115bffdd417c6d806ea9069237a4ae02f513b778e3789a359bc5856e0404cc4"}, + {file = "rpds_py-0.22.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:ea7433ce7e4bfc3a85654aeb6747babe3f66eaf9a1d0c1e7a4435bbdf27fea84"}, + {file = "rpds_py-0.22.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6dd9412824c4ce1aca56c47b0991e65bebb7ac3f4edccfd3f156150c96a7bf25"}, + {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:20070c65396f7373f5df4005862fa162db5d25d56150bddd0b3e8214e8ef45b4"}, + {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0b09865a9abc0ddff4e50b5ef65467cd94176bf1e0004184eb915cbc10fc05c5"}, + {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3453e8d41fe5f17d1f8e9c383a7473cd46a63661628ec58e07777c2fff7196dc"}, + {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f5d36399a1b96e1a5fdc91e0522544580dbebeb1f77f27b2b0ab25559e103b8b"}, + {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:009de23c9c9ee54bf11303a966edf4d9087cd43a6003672e6aa7def643d06518"}, + {file = "rpds_py-0.22.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1aef18820ef3e4587ebe8b3bc9ba6e55892a6d7b93bac6d29d9f631a3b4befbd"}, + {file = "rpds_py-0.22.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f60bd8423be1d9d833f230fdbccf8f57af322d96bcad6599e5a771b151398eb2"}, + {file = "rpds_py-0.22.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:62d9cfcf4948683a18a9aff0ab7e1474d407b7bab2ca03116109f8464698ab16"}, + {file = "rpds_py-0.22.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9253fc214112405f0afa7db88739294295f0e08466987f1d70e29930262b4c8f"}, + {file = "rpds_py-0.22.3-cp313-cp313-win32.whl", hash = "sha256:fb0ba113b4983beac1a2eb16faffd76cb41e176bf58c4afe3e14b9c681f702de"}, + {file = "rpds_py-0.22.3-cp313-cp313-win_amd64.whl", hash = "sha256:c58e2339def52ef6b71b8f36d13c3688ea23fa093353f3a4fee2556e62086ec9"}, + {file = "rpds_py-0.22.3-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f82a116a1d03628a8ace4859556fb39fd1424c933341a08ea3ed6de1edb0283b"}, + {file = "rpds_py-0.22.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3dfcbc95bd7992b16f3f7ba05af8a64ca694331bd24f9157b49dadeeb287493b"}, + {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:59259dc58e57b10e7e18ce02c311804c10c5a793e6568f8af4dead03264584d1"}, + {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5725dd9cc02068996d4438d397e255dcb1df776b7ceea3b9cb972bdb11260a83"}, + {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:99b37292234e61325e7a5bb9689e55e48c3f5f603af88b1642666277a81f1fbd"}, + {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:27b1d3b3915a99208fee9ab092b8184c420f2905b7d7feb4aeb5e4a9c509b8a1"}, + {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f612463ac081803f243ff13cccc648578e2279295048f2a8d5eb430af2bae6e3"}, + {file = "rpds_py-0.22.3-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f73d3fef726b3243a811121de45193c0ca75f6407fe66f3f4e183c983573e130"}, + {file = "rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:3f21f0495edea7fdbaaa87e633a8689cd285f8f4af5c869f27bc8074638ad69c"}, + {file = "rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:1e9663daaf7a63ceccbbb8e3808fe90415b0757e2abddbfc2e06c857bf8c5e2b"}, + {file = "rpds_py-0.22.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a76e42402542b1fae59798fab64432b2d015ab9d0c8c47ba7addddbaf7952333"}, + {file = "rpds_py-0.22.3-cp313-cp313t-win32.whl", hash = "sha256:69803198097467ee7282750acb507fba35ca22cc3b85f16cf45fb01cb9097730"}, + {file = "rpds_py-0.22.3-cp313-cp313t-win_amd64.whl", hash = "sha256:f5cf2a0c2bdadf3791b5c205d55a37a54025c6e18a71c71f82bb536cf9a454bf"}, + {file = "rpds_py-0.22.3-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:378753b4a4de2a7b34063d6f95ae81bfa7b15f2c1a04a9518e8644e81807ebea"}, + {file = "rpds_py-0.22.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3445e07bf2e8ecfeef6ef67ac83de670358abf2996916039b16a218e3d95e97e"}, + {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7b2513ba235829860b13faa931f3b6846548021846ac808455301c23a101689d"}, + {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:eaf16ae9ae519a0e237a0f528fd9f0197b9bb70f40263ee57ae53c2b8d48aeb3"}, + {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:583f6a1993ca3369e0f80ba99d796d8e6b1a3a2a442dd4e1a79e652116413091"}, + {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4617e1915a539a0d9a9567795023de41a87106522ff83fbfaf1f6baf8e85437e"}, + {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0c150c7a61ed4a4f4955a96626574e9baf1adf772c2fb61ef6a5027e52803543"}, + {file = "rpds_py-0.22.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2fa4331c200c2521512595253f5bb70858b90f750d39b8cbfd67465f8d1b596d"}, + {file = "rpds_py-0.22.3-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:214b7a953d73b5e87f0ebece4a32a5bd83c60a3ecc9d4ec8f1dca968a2d91e99"}, + {file = "rpds_py-0.22.3-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:f47ad3d5f3258bd7058d2d506852217865afefe6153a36eb4b6928758041d831"}, + {file = "rpds_py-0.22.3-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:f276b245347e6e36526cbd4a266a417796fc531ddf391e43574cf6466c492520"}, + {file = "rpds_py-0.22.3-cp39-cp39-win32.whl", hash = "sha256:bbb232860e3d03d544bc03ac57855cd82ddf19c7a07651a7c0fdb95e9efea8b9"}, + {file = "rpds_py-0.22.3-cp39-cp39-win_amd64.whl", hash = "sha256:cfbc454a2880389dbb9b5b398e50d439e2e58669160f27b60e5eca11f68ae17c"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:d48424e39c2611ee1b84ad0f44fb3b2b53d473e65de061e3f460fc0be5f1939d"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:24e8abb5878e250f2eb0d7859a8e561846f98910326d06c0d51381fed59357bd"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b232061ca880db21fa14defe219840ad9b74b6158adb52ddf0e87bead9e8493"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ac0a03221cdb5058ce0167ecc92a8c89e8d0decdc9e99a2ec23380793c4dcb96"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:eb0c341fa71df5a4595f9501df4ac5abfb5a09580081dffbd1ddd4654e6e9123"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bf9db5488121b596dbfc6718c76092fda77b703c1f7533a226a5a9f65248f8ad"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0b8db6b5b2d4491ad5b6bdc2bc7c017eec108acbf4e6785f42a9eb0ba234f4c9"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b3d504047aba448d70cf6fa22e06cb09f7cbd761939fdd47604f5e007675c24e"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:e61b02c3f7a1e0b75e20c3978f7135fd13cb6cf551bf4a6d29b999a88830a338"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-musllinux_1_2_i686.whl", hash = "sha256:e35ba67d65d49080e8e5a1dd40101fccdd9798adb9b050ff670b7d74fa41c566"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:26fd7cac7dd51011a245f29a2cc6489c4608b5a8ce8d75661bb4a1066c52dfbe"}, + {file = "rpds_py-0.22.3-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:177c7c0fce2855833819c98e43c262007f42ce86651ffbb84f37883308cb0e7d"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:bb47271f60660803ad11f4c61b42242b8c1312a31c98c578f79ef9387bbde21c"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:70fb28128acbfd264eda9bf47015537ba3fe86e40d046eb2963d75024be4d055"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:44d61b4b7d0c2c9ac019c314e52d7cbda0ae31078aabd0f22e583af3e0d79723"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f0e260eaf54380380ac3808aa4ebe2d8ca28b9087cf411649f96bad6900c728"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b25bc607423935079e05619d7de556c91fb6adeae9d5f80868dde3468657994b"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fb6116dfb8d1925cbdb52595560584db42a7f664617a1f7d7f6e32f138cdf37d"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a63cbdd98acef6570c62b92a1e43266f9e8b21e699c363c0fef13bd530799c11"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2b8f60e1b739a74bab7e01fcbe3dddd4657ec685caa04681df9d562ef15b625f"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2e8b55d8517a2fda8d95cb45d62a5a8bbf9dd0ad39c5b25c8833efea07b880ca"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-musllinux_1_2_i686.whl", hash = "sha256:2de29005e11637e7a2361fa151f780ff8eb2543a0da1413bb951e9f14b699ef3"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:666ecce376999bf619756a24ce15bb14c5bfaf04bf00abc7e663ce17c3f34fe7"}, + {file = "rpds_py-0.22.3-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:5246b14ca64a8675e0a7161f7af68fe3e910e6b90542b4bfb5439ba752191df6"}, + {file = "rpds_py-0.22.3.tar.gz", hash = "sha256:e32fee8ab45d3c2db6da19a5323bc3362237c8b653c70194414b892fd06a080d"}, +] + [[package]] name = "sqlparse" version = "0.5.3" @@ -495,6 +781,19 @@ files = [ dev = ["build", "hatch"] doc = ["sphinx"] +[[package]] +name = "typing-extensions" +version = "4.12.2" +description = "Backported and Experimental Type Hints for Python 3.8+" +optional = false +python-versions = ">=3.8" +groups = ["main"] +markers = "python_version < \"3.13\"" +files = [ + {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, + {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, +] + [[package]] name = "tzdata" version = "2025.1" @@ -508,7 +807,19 @@ files = [ {file = "tzdata-2025.1.tar.gz", hash = "sha256:24894909e88cdb28bd1636c6887801df64cb485bd593f2fd83ef29075a81d694"}, ] +[[package]] +name = "uritemplate" +version = "4.1.1" +description = "Implementation of RFC 6570 URI Templates" +optional = false +python-versions = ">=3.6" +groups = ["main"] +files = [ + {file = "uritemplate-4.1.1-py2.py3-none-any.whl", hash = "sha256:830c08b8d99bdd312ea4ead05994a38e8936266f84b9a7878232db50b044e02e"}, + {file = "uritemplate-4.1.1.tar.gz", hash = "sha256:4346edfc5c3b79f694bccd6d6099a322bbeb628dbf2cd86eea55a456ce5124f0"}, +] + [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "2f6fb2cf091971505cd40f5d8e6692a18a3caef39a737f078f33a811189cbfc8" +content-hash = "c2a77b1ad8037c15bc51dc78a25aa63ab7f086f6c0a4815b2ba10036652810bf" diff --git a/pyproject.toml b/pyproject.toml index c9ca1cb..15bc38f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -15,6 +15,7 @@ djangorestframework = "^3.14.0" djangorestframework-simplejwt = "^5.3.1" pyotp = "^2.9.0" django-ratelimit = "^4.1.0" +drf-spectacular = "^0.28.0" [tool.poetry.group.dev.dependencies] pytest = "^7.4.4" From f13e69d43069305ded5cdba8cb7b5821799175a4 Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 13 Feb 2025 17:03:17 +0000 Subject: [PATCH 8/8] Fix pr #13: Fix issue #11: Implement User Authentication API Endpoints --- users/serializers.py | 57 +++++++++++++++++++---- users/views.py | 106 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 153 insertions(+), 10 deletions(-) diff --git a/users/serializers.py b/users/serializers.py index 6c0ea78..ead0d52 100644 --- a/users/serializers.py +++ b/users/serializers.py @@ -7,12 +7,24 @@ User = get_user_model() class UserRegistrationSerializer(serializers.ModelSerializer): - password = serializers.CharField(write_only=True, required=True) - password_confirm = serializers.CharField(write_only=True, required=True) + password = serializers.CharField( + write_only=True, + required=True, + help_text="User's password (must meet complexity requirements)" + ) + password_confirm = serializers.CharField( + write_only=True, + required=True, + help_text="Password confirmation (must match password)" + ) class Meta: model = User fields = ('email', 'username', 'password', 'password_confirm') + extra_kwargs = { + 'email': {'help_text': "User's email address"}, + 'username': {'help_text': "User's desired username"} + } def validate(self, data): if data['password'] != data['password_confirm']: @@ -33,17 +45,34 @@ def create(self, validated_data): return user class UserLoginSerializer(serializers.Serializer): - email = serializers.EmailField() - password = serializers.CharField() - mfa_code = serializers.CharField(required=False) + email = serializers.EmailField( + help_text="User's email address" + ) + password = serializers.CharField( + help_text="User's password" + ) + mfa_code = serializers.CharField( + required=False, + help_text="6-digit MFA code (required if MFA is enabled)" + ) class PasswordResetRequestSerializer(serializers.Serializer): - email = serializers.EmailField() + email = serializers.EmailField( + help_text="Email address of the account to reset password" + ) class PasswordResetConfirmSerializer(serializers.Serializer): - token = serializers.CharField() - password = serializers.CharField(write_only=True) - password_confirm = serializers.CharField(write_only=True) + token = serializers.CharField( + help_text="Password reset token received via email" + ) + password = serializers.CharField( + write_only=True, + help_text="New password (must meet complexity requirements)" + ) + password_confirm = serializers.CharField( + write_only=True, + help_text="New password confirmation (must match password)" + ) def validate(self, data): if data['password'] != data['password_confirm']: @@ -55,10 +84,18 @@ def validate(self, data): return data class MFAEnableSerializer(serializers.ModelSerializer): + """ + Serializer for enabling MFA. Returns the secret key and QR code URI. + """ class Meta: model = MFADevice fields = ('secret_key',) read_only_fields = ('secret_key',) + extra_kwargs = { + 'secret_key': {'help_text': 'TOTP secret key for MFA setup'} + } class MFAVerifySerializer(serializers.Serializer): - code = serializers.CharField() + code = serializers.CharField( + help_text="6-digit TOTP verification code" + ) diff --git a/users/views.py b/users/views.py index 5be72e0..1643d1d 100644 --- a/users/views.py +++ b/users/views.py @@ -23,10 +23,27 @@ User = get_user_model() class RegisterView(APIView): + """ + Register a new user. + + Accepts POST requests with the following data: + * email - User's email address + * username - User's desired username + * password - User's password + * password_confirm - Password confirmation + """ permission_classes = [permissions.AllowAny] + serializer_class = UserRegistrationSerializer @method_decorator(ratelimit(key='ip', rate='5/m', method=['POST'])) def post(self, request): + """ + Create a new user account. + + Returns: + 201: User registered successfully + 400: Invalid input data + """ serializer = UserRegistrationSerializer(data=request.data) if serializer.is_valid(): user = serializer.save() @@ -37,10 +54,27 @@ def post(self, request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class LoginView(APIView): + """ + Authenticate a user and return JWT tokens. + + Accepts POST requests with the following data: + * email - User's email address + * password - User's password + * mfa_code - MFA verification code (required if MFA is enabled) + """ permission_classes = [permissions.AllowAny] + serializer_class = UserLoginSerializer @method_decorator(ratelimit(key='ip', rate='5/m', method=['POST'])) def post(self, request): + """ + Authenticate user and return tokens. + + Returns: + 200: Authentication successful, returns access and refresh tokens + 400: Invalid input data + 401: Invalid credentials or MFA code + """ serializer = UserLoginSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -94,9 +128,22 @@ def post(self, request): }) class LogoutView(APIView): + """ + Logout user by invalidating their refresh token. + + Accepts POST requests with the following data: + * refresh - JWT refresh token to invalidate + """ permission_classes = [permissions.IsAuthenticated] def post(self, request): + """ + Invalidate refresh token and logout user. + + Returns: + 200: Successfully logged out + 400: Invalid token + """ try: refresh_token = request.data["refresh"] token = RefreshToken(refresh_token) @@ -116,10 +163,24 @@ def post(self, request): ) class PasswordResetView(APIView): + """ + Request a password reset email. + + Accepts POST requests with the following data: + * email - Email address of the account to reset password + """ permission_classes = [permissions.AllowAny] + serializer_class = PasswordResetRequestSerializer @method_decorator(ratelimit(key='ip', rate='3/m', method=['POST'])) def post(self, request): + """ + Send password reset instructions via email. + + Returns: + 200: Password reset instructions sent + 400: Invalid input data + """ serializer = PasswordResetRequestSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -151,9 +212,25 @@ def post(self, request): ) class PasswordResetConfirmView(APIView): + """ + Confirm password reset and set new password. + + Accepts POST requests with the following data: + * token - Password reset token received via email + * password - New password + * password_confirm - New password confirmation + """ permission_classes = [permissions.AllowAny] + serializer_class = PasswordResetConfirmSerializer def post(self, request): + """ + Reset password using the provided token. + + Returns: + 200: Password reset successful + 400: Invalid token or password validation failed + """ serializer = PasswordResetConfirmSerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) @@ -176,9 +253,24 @@ def post(self, request): ) class MFAEnableView(APIView): + """ + Enable Multi-Factor Authentication (MFA) for the authenticated user. + + Returns: + * secret - MFA secret key for TOTP setup + * qr_code_uri - QR code URI for scanning with authenticator app + """ permission_classes = [permissions.IsAuthenticated] + serializer_class = MFAEnableSerializer def post(self, request): + """ + Generate MFA secret and QR code URI. + + Returns: + 200: MFA setup data returned successfully + 400: MFA is already enabled + """ if hasattr(request.user, 'mfa_device'): return Response( {"error": "MFA is already enabled"}, @@ -203,9 +295,23 @@ def post(self, request): }) class MFAVerifyView(APIView): + """ + Verify MFA code and enable MFA for the authenticated user. + + Accepts POST requests with the following data: + * code - 6-digit TOTP verification code + """ permission_classes = [permissions.IsAuthenticated] + serializer_class = MFAVerifySerializer def post(self, request): + """ + Verify MFA code and enable MFA. + + Returns: + 200: MFA enabled successfully + 400: Invalid MFA code or validation failed + """ serializer = MFAVerifySerializer(data=request.data) if not serializer.is_valid(): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)