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 diff --git a/auth_system/settings.py b/auth_system/settings.py index a60f08d..6a7163b 100644 --- a/auth_system/settings.py +++ b/auth_system/settings.py @@ -42,6 +42,7 @@ 'django.contrib.messages', 'django.contrib.staticfiles', 'rest_framework', + 'rest_framework_simplejwt.token_blacklist', 'drf_spectacular', 'users', ] @@ -52,8 +53,38 @@ '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') + # Spectacular settings SPECTACULAR_SETTINGS = { 'TITLE': 'Authentication System API', @@ -72,7 +103,6 @@ } ], } - # Custom User model AUTH_USER_MODEL = 'users.User' diff --git a/auth_system/urls.py b/auth_system/urls.py index 1a143b4..da4994f 100644 --- a/auth_system/urls.py +++ b/auth_system/urls.py @@ -20,6 +20,7 @@ urlpatterns = [ path('admin/', admin.site.urls), + path('api/', include('users.urls')), # API Documentation URLs path('api/schema/', SpectacularAPIView.as_view(), name='schema'), diff --git a/poetry.lock b/poetry.lock index 518372e..22acb74 100644 --- a/poetry.lock +++ b/poetry.lock @@ -208,6 +208,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" @@ -437,7 +449,6 @@ files = [ {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bb89f0a835bcfc1d42ccd5f41f04870c1b936d8507c6df12b7737febc40f0909"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f0c2d907a1e102526dd2986df638343388b94c33860ff3bbe1384130828714b1"}, {file = "psycopg2_binary-2.9.10-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f8157bed2f51db683f31306aa497311b560f2265998122abe1dce6428bd86567"}, - {file = "psycopg2_binary-2.9.10-cp313-cp313-win_amd64.whl", hash = "sha256:27422aa5f11fbcd9b18da48373eb67081243662f9b46e6fd07c3eb46e4535142"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-macosx_12_0_x86_64.whl", hash = "sha256:eb09aa7f9cecb45027683bb55aebaaf45a0df8bf6de68801a6afdc7947bb09d4"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b73d6d7f0ccdad7bc43e6d34273f70d587ef62f824d7261c4ae9b8b1b6af90e8"}, {file = "psycopg2_binary-2.9.10-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ce5ab4bf46a211a8e924d307c1b1fcda82368586a19d0a24f8ae166f5c784864"}, @@ -490,6 +501,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" @@ -795,4 +821,4 @@ files = [ [metadata] lock-version = "2.1" python-versions = "^3.11" -content-hash = "25a162b70bddbba81e548ba30f1509d940e9da4ff846c8c0535c7b959727b198" +content-hash = "c2a77b1ad8037c15bc51dc78a25aa63ab7f086f6c0a4815b2ba10036652810bf" diff --git a/pyproject.toml b/pyproject.toml index 3c27851..15bc38f 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" drf-spectacular = "^0.28.0" [tool.poetry.group.dev.dependencies] diff --git a/users/serializers.py b/users/serializers.py new file mode 100644 index 0000000..ead0d52 --- /dev/null +++ b/users/serializers.py @@ -0,0 +1,101 @@ +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, + 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']: + 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( + 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( + help_text="Email address of the account to reset password" + ) + +class PasswordResetConfirmSerializer(serializers.Serializer): + 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']: + 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): + """ + 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( + help_text="6-digit TOTP verification code" + ) diff --git a/users/tests/views/__init__.py b/users/tests/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/users/tests/views/test_auth.py b/users/tests/views/test_auth.py new file mode 100644 index 0000000..91542f6 --- /dev/null +++ b/users/tests/views/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 users.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..1643d1d 100644 --- a/users/views.py +++ b/users/views.py @@ -1,3 +1,331 @@ -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): + """ + 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() + return Response( + {"message": "User registered successfully"}, + status=status.HTTP_201_CREATED + ) + 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) + + 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): + """ + 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) + 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): + """ + 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) + + 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): + """ + 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) + + 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): + """ + 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"}, + 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): + """ + 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) + + 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 + )