diff --git a/backend/core/settings.py b/backend/core/settings.py index 6167530..5bcb92b 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -13,6 +13,7 @@ import os from pathlib import Path from dotenv import load_dotenv +from datetime import timedelta # Build paths inside the project like this: BASE_DIR / 'subdir'. BASE_DIR = Path(__file__).resolve().parent.parent @@ -39,6 +40,7 @@ "django.contrib.messages", "django.contrib.staticfiles", "rest_framework", + "rest_framework_simplejwt.token_blacklist", "corsheaders", "users", "inventory", @@ -140,7 +142,14 @@ "rest_framework.permissions.IsAuthenticatedOrReadOnly", ], "DEFAULT_AUTHENTICATION_CLASSES": [ - "rest_framework.authentication.SessionAuthentication", - "rest_framework.authentication.TokenAuthentication", # Or JWT + "rest_framework_simplejwt.authentication.JWTAuthentication", # Or JWT ], } + +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=1), # They can renew for 24 hours + "ROTATE_REFRESH_TOKENS": False, + "BLACKLIST_AFTER_ROTATION": False, + "AUTH_HEADER_TYPES": ("Bearer",), # Prefix +} diff --git a/backend/core/urls.py b/backend/core/urls.py index 0a2ba39..ec3131d 100644 --- a/backend/core/urls.py +++ b/backend/core/urls.py @@ -21,7 +21,7 @@ urlpatterns = [ path("admin/", admin.site.urls), # API Routes - Uncomment when ready to use - # path('api/', include('users.urls')), + path("api/", include("users.urls")), # path('api/inventory/', include('inventory.urls')), # path('api/', include('requests.urls')), ] diff --git a/backend/requirements.txt b/backend/requirements.txt index c8a74a6..a1a59f9 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/backend/users/permissions.py b/backend/users/permissions.py new file mode 100644 index 0000000..a326ba7 --- /dev/null +++ b/backend/users/permissions.py @@ -0,0 +1,36 @@ +from rest_framework import permissions + + +class IsAdmin(permissions.BasePermission): + """ + Allows access only to admims + """ + + def has_permission(self, request, view): + # Logged in and ADMIN + return bool(request.user and request.user.is_authenticated and request.user.role == "ADMIN") + + +class IsVolunteer(permissions.BasePermission): + """ + Allows access to volunteers and admins + """ + + def has_permission(self, request, view): + # Logged in and VOLUNTEER or ADMIN + return bool(request.user and request.user.is_authenticated and request.user.role in ["VOLUNTEER", "ADMIN"]) + + +class IsActiveAndNotExpired(permissions.BasePermission): + """ + Global check: Is the user active? Has their access expired? + """ + + def has_permission(self, request, view): + # 1. Standard authen check + if not request.user or not request.user.is_authenticated: + return False + + # Since already cross checking server for user info, might as well add this check for security/instant bans + # This checks: is_active=True AND access_expires_at > Now + return request.user.has_active_access() diff --git a/backend/users/serializers.py b/backend/users/serializers.py index 9de63fd..7316973 100644 --- a/backend/users/serializers.py +++ b/backend/users/serializers.py @@ -1,33 +1,44 @@ from rest_framework import serializers -# from .models import User +from .models import User # Create your serializers here. # Example: User Serializer # Uncomment and modify as needed -# -# class UserSerializer(serializers.ModelSerializer): -# """ -# Serializer for the User model. -# Controls what fields are exposed in the API. -# """ -# class Meta: -# model = User -# fields = ['id', 'username', 'email', 'first_name', 'last_name', 'date_joined'] -# read_only_fields = ['id', 'date_joined'] -# -# -# class UserCreateSerializer(serializers.ModelSerializer): -# """ -# Serializer for creating new users with password handling. -# """ -# password = serializers.CharField(write_only=True, min_length=8, style={'input_type': 'password'}) -# -# class Meta: -# model = User -# fields = ['username', 'email', 'password', 'first_name', 'last_name'] -# -# def create(self, validated_data): -# """Create user with encrypted password.""" -# return User.objects.create_user(**validated_data) + + +class UserSerializer(serializers.ModelSerializer): + """ + Serializer for the User model. + Controls what fields are exposed in the API. + """ + + class Meta: + model = User + # Changed 'date_joined' to 'created_at' + # Also added 'role' so frontend can see if ADMIN or VOLUNTEER + fields = ["id", "name", "email", "created_at", "role"] + read_only_fields = ["id", "created_at", "role"] + + +class UserRegistrationSerializer(serializers.ModelSerializer): + """ + Used ONLY for registering new users + """ + + password = serializers.CharField(write_only=True, style={"input_type": "password"}) + + class Meta: + model = User + fields = ["email", "name", "password"] + + def create(self, validated_data): + # Need to overwrite create to handle password hashing + user = User.objects.create_user( + email=validated_data["email"], + name=validated_data["name"], + password=validated_data["password"], + role="VOLUNTEER", # Default role for new signups, should this be configurable + ) + return user diff --git a/backend/users/tests.py b/backend/users/tests.py index 2e221d4..a11eff7 100644 --- a/backend/users/tests.py +++ b/backend/users/tests.py @@ -1,4 +1,6 @@ import pytest +from django.urls import reverse +from rest_framework import status from django.contrib.auth import get_user_model User = get_user_model() @@ -6,8 +8,46 @@ @pytest.mark.django_db def test_create_user(): - """Test creating a user""" + """Test creating basic user""" user = User.objects.create_user(email="test@example.com", name="Test User", password="testpass123") assert user.email == "test@example.com" assert user.name == "Test User" assert user.check_password("testpass123") + + +@pytest.mark.django_db +def test_login_success(client): + """Test JWT login success""" + User.objects.create_user(email="login@example.com", name="Login User", password="password123") + + url = "/api/auth/login/" + data = {"email": "login@example.com", "password": "password123"} + + response = client.post(url, data) + + assert response.status_code == status.HTTP_200_OK + assert "access" in response.data + assert "refresh" in response.data + + +@pytest.mark.django_db +def test_logout_blacklists_token(client): + """Test JWT Blacklist on logout""" + user = User.objects.create_user(email="logout@example.com", name="Logout User", password="password123") + + # Login to get tokens + login_url = "/api/auth/login/" + login_res = client.post( + login_url, {"email": "logout@example.com", "password": "password123"}, content_type="application/json" + ) + + access = login_res.data["access"] + refresh = login_res.data["refresh"] + + # Call Logout with the Authorization header manually added to avoid conftest + logout_url = "/api/auth/logout/" + response = client.post( + logout_url, {"refresh": refresh}, content_type="application/json", HTTP_AUTHORIZATION=f"Bearer {access}" + ) + + assert response.status_code == status.HTTP_205_RESET_CONTENT diff --git a/backend/users/urls.py b/backend/users/urls.py index 3a04e3e..9bbd813 100644 --- a/backend/users/urls.py +++ b/backend/users/urls.py @@ -1,27 +1,18 @@ -from django.urls import path, include -from rest_framework.routers import DefaultRouter +from django.urls import path +from rest_framework_simplejwt.views import ( + TokenObtainPairView, # The built-in "Login" view + TokenRefreshView, # The built-in "Refresh Session" view +) +from users.views import LogoutView, RegisterView, UserProfileView -# from .views import UserViewSet -# Create your URL patterns here. - -# Example: Using DRF Router for ViewSets -# Uncomment and modify as needed -# -# router = DefaultRouter() -# router.register(r'users', UserViewSet, basename='user') -# -# urlpatterns = [ -# path('', include(router.urls)), -# ] - -# This will create the following endpoints: -# GET /api/users/ - List all users -# POST /api/users/ - Create a new user -# GET /api/users/{id}/ - Retrieve a specific user -# PUT /api/users/{id}/ - Update a user -# PATCH /api/users/{id}/ - Partial update -# DELETE /api/users/{id}/ - Delete a user -# GET /api/users/me/ - Custom action (if defined in viewset) - -urlpatterns = [] +urlpatterns = [ + # The 'api/' part is already handled in core/urls.py + # Auth Routes + path("auth/register/", RegisterView.as_view(), name="auth_register"), + path("auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"), + path("auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"), + path("auth/logout/", LogoutView.as_view(), name="auth_logout"), + # User Routes + path("users/me/", UserProfileView.as_view(), name="user_profile"), +] diff --git a/backend/users/views.py b/backend/users/views.py index d228035..ff896ce 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -1,35 +1,42 @@ -from rest_framework import viewsets, permissions, status -from rest_framework.decorators import action +from rest_framework import generics, status, permissions from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework_simplejwt.tokens import RefreshToken +from .serializers import UserRegistrationSerializer, UserSerializer -# from .models import User -# from .serializers import UserSerializer, UserCreateSerializer - -# Create your views here. - -# Example: User ViewSet -# Uncomment and modify as needed -# -# class UserViewSet(viewsets.ModelViewSet): -# """ -# ViewSet for managing users. -# Provides CRUD operations: list, create, retrieve, update, delete -# """ -# queryset = User.objects.all() -# serializer_class = UserSerializer -# permission_classes = [permissions.IsAuthenticatedOrReadOnly] -# -# def get_serializer_class(self): -# """Return appropriate serializer based on action.""" -# if self.action == 'create': -# return UserCreateSerializer -# return UserSerializer -# -# @action(detail=False, methods=['get'], permission_classes=[permissions.IsAuthenticated]) -# def me(self, request): -# """ -# Custom endpoint: GET /api/users/me/ -# Returns the current authenticated user. -# """ -# serializer = self.get_serializer(request.user) -# return Response(serializer.data) + +# Register new account +class RegisterView(generics.CreateAPIView): + """ + Endpoint: POST /api/auth/register/ + """ + + serializer_class = UserRegistrationSerializer + permission_classes = [permissions.AllowAny] # Open to public + + +# User Profile to check own data +class UserProfileView(APIView): + """ + Endpoint: GET /api/users/me/ + """ + + permission_classes = [permissions.IsAuthenticated] + + def get(self, request): + serializer = UserSerializer(request.user) + return Response(serializer.data) + + +# Logout (Needs both refresh and access tokens) +class LogoutView(APIView): + permission_classes = (permissions.IsAuthenticated,) + + def post(self, request): + try: + refresh_token = request.data["refresh"] + token = RefreshToken(refresh_token) + token.blacklist() + return Response(status=status.HTTP_205_RESET_CONTENT) + except Exception: + return Response(status=status.HTTP_400_BAD_REQUEST) diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..a649cc1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "made", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d250f05 Binary files /dev/null and b/requirements.txt differ