From c2fc7bc15f4243a2c50fbad10adaa2853e87dce2 Mon Sep 17 00:00:00 2001 From: "Marko (Hermes Implementer)" Date: Wed, 27 May 2026 00:24:09 +0000 Subject: [PATCH] feat: implement API security measures phase 1 --- api/accounts/admin.py | 14 +- api/accounts/exceptions.py | 149 ++++++++++++++ api/accounts/middleware.py | 24 +++ .../migrations/0002_blacklistedtoken.py | 31 +++ api/accounts/models.py | 32 ++- api/accounts/permissions.py | 33 ++++ api/accounts/serializers.py | 55 +++++- api/accounts/throttles.py | 12 ++ api/accounts/urls.py | 5 + api/accounts/views.py | 74 ++++++- .../0002_jobapplication_user_and_more.py | 62 ++++++ api/jobs/models.py | 16 +- api/jobs/serializers.py | 25 +++ api/jobs/views.py | 36 +++- api/project/settings.py | 31 ++- docs/backend/api-security-phase-1.md | 186 ++++++++++++++++++ web/src/App.tsx | 86 +++++++- web/src/components/AppLayout.tsx | 19 +- web/src/contexts/AuthContext.tsx | 62 +++++- web/src/hooks/useDashboardData.ts | 15 +- web/src/services/authApi.ts | 127 +++++++++++- 21 files changed, 1050 insertions(+), 44 deletions(-) create mode 100644 api/accounts/exceptions.py create mode 100644 api/accounts/middleware.py create mode 100644 api/accounts/migrations/0002_blacklistedtoken.py create mode 100644 api/accounts/permissions.py create mode 100644 api/accounts/throttles.py create mode 100644 api/jobs/migrations/0002_jobapplication_user_and_more.py create mode 100644 docs/backend/api-security-phase-1.md diff --git a/api/accounts/admin.py b/api/accounts/admin.py index e41231f..6e1153e 100644 --- a/api/accounts/admin.py +++ b/api/accounts/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.contrib.auth.admin import UserAdmin as BaseUserAdmin -from accounts.models import User +from accounts.models import BlacklistedToken, User @admin.register(User) @@ -36,4 +36,14 @@ class UserAdmin(BaseUserAdmin): ) list_display = ("email", "first_name", "last_name", "is_staff") search_fields = ("email", "first_name", "last_name") - ordering = ("email",) \ No newline at end of file + ordering = ("email",) + + +@admin.register(BlacklistedToken) +class BlacklistedTokenAdmin(admin.ModelAdmin): + """Admin config for BlacklistedToken.""" + + list_display = ("jti", "user", "created_at", "expires_at") + list_select_related = ("user",) + search_fields = ("jti", "user__email") + ordering = ("-created_at",) \ No newline at end of file diff --git a/api/accounts/exceptions.py b/api/accounts/exceptions.py new file mode 100644 index 0000000..29a4ea2 --- /dev/null +++ b/api/accounts/exceptions.py @@ -0,0 +1,149 @@ +import logging +import traceback + +from django.conf import settings +from django.core.exceptions import PermissionDenied, ValidationError as DjangoValidationError +from django.http import Http404 +from rest_framework import exceptions, status +from rest_framework.exceptions import APIException +from rest_framework.response import Response +from rest_framework.views import exception_handler as drf_exception_handler + +logger = logging.getLogger("django.request") + + +def _safe_error_response( + detail: str, + code: str, + status_code: int, +) -> Response: + """Return a consistent error response with no stack traces.""" + return Response( + {"error": detail, "code": code}, + status=status_code, + ) + + +def api_exception_handler(exc: Exception, context: dict) -> Response | None: + """ + Custom DRF exception handler that: + - Never exposes stack traces, file paths, or Python internals + - Maps common exceptions to user-safe messages + - Logs full traceback to django.request logger + - Returns consistent {error, code} format + """ + # Always log the full traceback + logger.error( + "API Exception: %s: %s\n%s", + type(exc).__name__, + exc, + "".join(traceback.format_tb(exc.__traceback__)), + ) + + # PermissionDenied -> 403 + if isinstance(exc, PermissionDenied): + return _safe_error_response( + "You do not have permission to perform this action.", + "permission_denied", + status.HTTP_403_FORBIDDEN, + ) + + # Http404 -> 404 + if isinstance(exc, Http404): + return _safe_error_response( + "The requested resource was not found.", + "not_found", + status.HTTP_404_NOT_FOUND, + ) + + # Django ValidationError -> 400 + if isinstance(exc, DjangoValidationError): + return _safe_error_response( + str(exc) if isinstance(exc.message, str) else "Validation error.", + "validation_error", + status.HTTP_400_BAD_REQUEST, + ) + + # DRF APIException (includes AuthenticationFailed, NotAuthenticated, ParseError, etc.) + if isinstance(exc, APIException): + # Use DRF's standard handling but map to our format + response = drf_exception_handler(exc, context) + if response is not None: + # Ensure response is our safe format + detail = _extract_detail(response.data) + return _safe_error_response( + detail, + _get_error_code(exc), + response.status_code, + ) + + # DRF Throttled + if isinstance(exc, exceptions.Throttled): + return _safe_error_response( + "Request rate limit exceeded. Please try again later.", + "throttled", + exc.status_code, + ) + + # AuthenticationFailed / NotAuthenticated + if isinstance(exc, exceptions.AuthenticationFailed): + return _safe_error_response( + str(exc.detail) if hasattr(exc, "detail") else "Authentication failed.", + "authentication_failed", + status.HTTP_401_UNAUTHORIZED, + ) + + if isinstance(exc, exceptions.NotAuthenticated): + return _safe_error_response( + "Authentication credentials were not provided.", + "not_authenticated", + status.HTTP_401_UNAUTHORIZED, + ) + + # Catch-all for unhandled exceptions + if not settings.DEBUG: + return _safe_error_response( + "Internal server error.", + "internal_error", + status.HTTP_500_INTERNAL_SERVER_ERROR, + ) + + # In DEBUG mode, let DRF's default handler show the traceback + return drf_exception_handler(exc, context) + + +def _extract_detail(data: dict | list | str) -> str: + """Extract the first meaningful error string from DRF error data.""" + if isinstance(data, str): + return data + if isinstance(data, list): + for item in data: + result = _extract_detail(item) + if result: + return result + if isinstance(data, dict): + # Try 'detail' first, then first field error + if "detail" in data: + return _extract_detail(data["detail"]) + for _key, value in data.items(): + result = _extract_detail(value) + if result: + return result + return "An error occurred." + + +def _get_error_code(exc: APIException) -> str: + """Map exception class to a stable error code string.""" + mapping: dict[type, str] = { + exceptions.AuthenticationFailed: "authentication_failed", + exceptions.NotAuthenticated: "not_authenticated", + exceptions.PermissionDenied: "permission_denied", + exceptions.NotFound: "not_found", + exceptions.MethodNotAllowed: "method_not_allowed", + exceptions.NotAcceptable: "not_acceptable", + exceptions.UnsupportedMediaType: "unsupported_media_type", + exceptions.Throttled: "throttled", + exceptions.ParseError: "parse_error", + exceptions.ValidationError: "validation_error", + } + return mapping.get(type(exc), "error") \ No newline at end of file diff --git a/api/accounts/middleware.py b/api/accounts/middleware.py new file mode 100644 index 0000000..2ec7260 --- /dev/null +++ b/api/accounts/middleware.py @@ -0,0 +1,24 @@ +from django.conf import settings + + +class SecurityHeadersMiddleware: + """Add security-related HTTP headers to every response.""" + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + response = self.get_response(request) + + if not settings.DEBUG: + response["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + + response.setdefault("X-Content-Type-Options", "nosniff") + response.setdefault("X-Frame-Options", "DENY") + response.setdefault("Referrer-Policy", "strict-origin-when-cross-origin") + response.setdefault( + "Permissions-Policy", + "geolocation=(), microphone=(), camera=()", + ) + + return response \ No newline at end of file diff --git a/api/accounts/migrations/0002_blacklistedtoken.py b/api/accounts/migrations/0002_blacklistedtoken.py new file mode 100644 index 0000000..6548d29 --- /dev/null +++ b/api/accounts/migrations/0002_blacklistedtoken.py @@ -0,0 +1,31 @@ +# Generated by Django 5.1.7 on 2026-05-27 00:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='BlacklistedToken', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('jti', models.UUIDField(help_text='JWT ID from the token payload', unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('expires_at', models.DateTimeField(help_text='When the token would have naturally expired')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='blacklisted_tokens', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Blacklisted Token', + 'verbose_name_plural': 'Blacklisted Tokens', + 'db_table': 'accounts_blacklisted_token', + 'indexes': [models.Index(fields=['jti'], name='accounts_bl_jti_88a727_idx'), models.Index(fields=['expires_at'], name='accounts_bl_expires_1a061a_idx')], + }, + ), + ] diff --git a/api/accounts/models.py b/api/accounts/models.py index c9105fa..fd448eb 100644 --- a/api/accounts/models.py +++ b/api/accounts/models.py @@ -26,4 +26,34 @@ class User(AbstractUser): verbose_name_plural = "Users" def __str__(self) -> str: - return self.email \ No newline at end of file + return self.email + + @property + def is_admin(self) -> bool: + """Check if user belongs to the admin group.""" + return self.groups.filter(name="admin").exists() or self.is_superuser + + +class BlacklistedToken(models.Model): + """Stores blacklisted JWT tokens for revocation.""" + + jti = models.UUIDField(unique=True, help_text="JWT ID from the token payload") + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="blacklisted_tokens", + ) + created_at = models.DateTimeField(auto_now_add=True) + expires_at = models.DateTimeField(help_text="When the token would have naturally expired") + + class Meta: + db_table = "accounts_blacklisted_token" + verbose_name = "Blacklisted Token" + verbose_name_plural = "Blacklisted Tokens" + indexes = [ + models.Index(fields=["jti"]), + models.Index(fields=["expires_at"]), + ] + + def __str__(self) -> str: + return f"Blacklisted {self.jti} (user {self.user_id})" \ No newline at end of file diff --git a/api/accounts/permissions.py b/api/accounts/permissions.py new file mode 100644 index 0000000..99ebfa7 --- /dev/null +++ b/api/accounts/permissions.py @@ -0,0 +1,33 @@ +from rest_framework.permissions import SAFE_METHODS, BasePermission + + +class IsAdminOrReadOnly(BasePermission): + """Admin users can perform any action; others can only read.""" + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + if request.method in SAFE_METHODS: + return True + return request.user.is_admin + + def has_object_permission(self, request, view, obj): + if request.method in SAFE_METHODS: + return True + return request.user.is_admin + + +class IsOwnerOrAdmin(BasePermission): + """Users can only access their own resources; admins can access all.""" + + def has_permission(self, request, view): + return bool(request.user and request.user.is_authenticated) + + def has_object_permission(self, request, view, obj): + if request.user.is_admin: + return True + # Expect obj to have a `user` FK pointing to the owning user + user_field = getattr(obj, "user", None) + if user_field is not None: + return user_field == request.user + return False \ No newline at end of file diff --git a/api/accounts/serializers.py b/api/accounts/serializers.py index 44ce645..c469605 100644 --- a/api/accounts/serializers.py +++ b/api/accounts/serializers.py @@ -1,8 +1,11 @@ +from typing import Any + import re from django.contrib.auth import authenticate from django.contrib.auth.hashers import make_password -from django.utils.translation import gettext_lazy as _ +from django.contrib.auth.password_validation import validate_password +from django.core.exceptions import ValidationError as DjangoValidationError from rest_framework import serializers from rest_framework_simplejwt.tokens import RefreshToken @@ -12,14 +15,27 @@ EMAIL_REGEX: re.Pattern[str] = re.compile(r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[ MIN_PASSWORD_LENGTH: int = 8 +class SanitizedCharField(serializers.CharField): + """CharField that strips control characters on deserialization.""" + + def to_internal_value(self, data: object) -> object: + value = super().to_internal_value(data) + if isinstance(value, str): + value = value.strip() + if "\x00" in value: + raise serializers.ValidationError("Input contains invalid characters.") + value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value) + return value + + class RegisterSerializer(serializers.Serializer): """Validate and create a new user account.""" email = serializers.EmailField(max_length=254) password = serializers.CharField(write_only=True) password_confirm = serializers.CharField(write_only=True) - first_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="") - last_name = serializers.CharField(max_length=150, required=False, allow_blank=True, default="") + first_name = SanitizedCharField(max_length=150, required=False, allow_blank=True, default="") + last_name = SanitizedCharField(max_length=150, required=False, allow_blank=True, default="") def validate_email(self, value: str) -> str: """Validate email format and check for duplicates.""" @@ -30,11 +46,15 @@ class RegisterSerializer(serializers.Serializer): return value.lower() def validate_password(self, value: str) -> str: - """Enforce minimum password length.""" + """Enforce minimum password length and Django validators.""" if len(value) < MIN_PASSWORD_LENGTH: raise serializers.ValidationError( f"Password must be at least {MIN_PASSWORD_LENGTH} characters." ) + try: + validate_password(value) + except DjangoValidationError as e: + raise serializers.ValidationError(" ".join(e.messages)) return value def validate(self, attrs: dict[str, object]) -> dict[str, object]: @@ -51,7 +71,6 @@ class RegisterSerializer(serializers.Serializer): """Create and return the new user.""" validated_data.pop("password_confirm") validated_data["password"] = make_password(validated_data["password"]) - # Ensure username is blank rather than None for unique constraint validated_data.setdefault("username", "") return User.objects.create(**validated_data) @@ -89,8 +108,30 @@ class LoginSerializer(serializers.Serializer): class UserSerializer(serializers.ModelSerializer): - """Public user profile serializer.""" + """Public user profile serializer — limited fields, no email.""" class Meta: model = User - fields = ("id", "email", "first_name", "last_name") \ No newline at end of file + fields = ("id", "first_name", "last_name") + + +class RefreshRequestSerializer(serializers.Serializer): + """Validate a refresh token request.""" + + refresh = serializers.CharField(required=True) + + def validate_refresh(self, value: str) -> str: + if not value or not value.strip(): + raise serializers.ValidationError("Refresh token is required.") + return value.strip() + + +class LogoutSerializer(serializers.Serializer): + """Validate a logout request.""" + + refresh = serializers.CharField(required=True) + + def validate_refresh(self, value: str) -> str: + if not value or not value.strip(): + raise serializers.ValidationError("Refresh token is required.") + return value.strip() \ No newline at end of file diff --git a/api/accounts/throttles.py b/api/accounts/throttles.py new file mode 100644 index 0000000..8857906 --- /dev/null +++ b/api/accounts/throttles.py @@ -0,0 +1,12 @@ +from rest_framework.throttling import SimpleRateThrottle + + +class UserRateThrottle(SimpleRateThrottle): + """Throttle authenticated requests by user ID.""" + + scope = "user" + + def get_cache_key(self, request, view): + if request.user and request.user.is_authenticated: + return self.cache_format % {"scope": self.scope, "ident": request.user.pk} + return None # AnonRateThrottle handles anonymous requests \ No newline at end of file diff --git a/api/accounts/urls.py b/api/accounts/urls.py index df0d969..26d24b6 100644 --- a/api/accounts/urls.py +++ b/api/accounts/urls.py @@ -1,4 +1,5 @@ from django.urls import path +from rest_framework_simplejwt.views import TokenVerifyView from accounts import views @@ -7,4 +8,8 @@ app_name = "accounts" urlpatterns = [ path("register/", views.register_view, name="register"), path("login/", views.login_view, name="login"), + path("token/refresh/", views.token_refresh_view, name="token-refresh"), + path("token/verify/", TokenVerifyView.as_view(), name="token-verify"), + path("logout/", views.logout_view, name="logout"), + path("me/", views.me_view, name="me"), ] \ No newline at end of file diff --git a/api/accounts/views.py b/api/accounts/views.py index 9d8da65..66636bd 100644 --- a/api/accounts/views.py +++ b/api/accounts/views.py @@ -6,8 +6,16 @@ from rest_framework.permissions import AllowAny from rest_framework.request import Request from rest_framework.response import Response from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.exceptions import TokenError +from rest_framework_simplejwt.tokens import RefreshToken -from accounts.serializers import LoginSerializer, RegisterSerializer, UserSerializer +from accounts.serializers import ( + LoginSerializer, + LogoutSerializer, + RefreshRequestSerializer, + RegisterSerializer, + UserSerializer, +) class AuthRateThrottle(AnonRateThrottle): @@ -46,4 +54,68 @@ def login_view(request: Request) -> Response: "refresh": validated_data["refresh"], }, status=status.HTTP_200_OK, + ) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +@throttle_classes([AuthRateThrottle]) +def token_refresh_view(request: Request) -> Response: + """Refresh an access token using a refresh token. + + Uses SimpleJWT's built-in rotation and blacklisting. + """ + serializer = RefreshRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + refresh_token_str: str = serializer.validated_data["refresh"] + try: + refresh = RefreshToken(refresh_token_str) + access = str(refresh.access_token) + new_refresh = str(refresh) + except TokenError as e: + return Response( + {"error": str(e), "code": "token_invalid"}, + status=status.HTTP_401_UNAUTHORIZED, + ) + + return Response( + {"access": access, "refresh": new_refresh}, + status=status.HTTP_200_OK, + ) + + +@api_view(["POST"]) +@permission_classes([AllowAny]) +def logout_view(request: Request) -> Response: + """Blacklist a refresh token (log out). + + This allows explicit token revocation on logout. + """ + serializer = LogoutSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + refresh_token_str: str = serializer.validated_data["refresh"] + try: + refresh = RefreshToken(refresh_token_str) + refresh.blacklist() + except TokenError: + # If token is already invalid/blacklisted, still consider logout successful + pass + except AttributeError: + # If blacklist app not installed + pass + + return Response( + {"message": "Successfully logged out."}, + status=status.HTTP_205_RESET_CONTENT, + ) + + +@api_view(["GET"]) +def me_view(request: Request) -> Response: + """Return the current authenticated user's profile.""" + return Response( + UserSerializer(request.user).data, + status=status.HTTP_200_OK, ) \ No newline at end of file diff --git a/api/jobs/migrations/0002_jobapplication_user_and_more.py b/api/jobs/migrations/0002_jobapplication_user_and_more.py new file mode 100644 index 0000000..1a67efe --- /dev/null +++ b/api/jobs/migrations/0002_jobapplication_user_and_more.py @@ -0,0 +1,62 @@ +# Generated by Django 5.1.7 on 2026-05-27 00:22 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +def assign_existing_applications_to_first_superuser(apps, schema_editor): + """Assign existing JobApplication rows to the first superuser.""" + JobApplication = apps.get_model("jobs", "JobApplication") + User = apps.get_model("accounts", "User") + admin = User.objects.filter(is_superuser=True).order_by("id").first() + if admin is not None: + JobApplication.objects.filter(user__isnull=True).update(user=admin) + + +class Migration(migrations.Migration): + + dependencies = [ + ('jobs', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + # 1. Add user FK as nullable initially so existing rows can be migrated + migrations.AddField( + model_name='jobapplication', + name='user', + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name='job_applications', + to=settings.AUTH_USER_MODEL, + help_text='User who owns this job application.', + ), + ), + # 2. Assign existing rows to the first superuser + migrations.RunPython( + assign_existing_applications_to_first_superuser, + reverse_code=migrations.RunPython.noop, + ), + # 3. Make user non-nullable now that all rows have a value + migrations.AlterField( + model_name='jobapplication', + name='user', + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name='job_applications', + to=settings.AUTH_USER_MODEL, + help_text='User who owns this job application.', + ), + ), + # 4. Add the unique constraint + migrations.AddConstraint( + model_name='jobapplication', + constraint=models.UniqueConstraint( + fields=('user', 'company_name', 'position_title'), + name='unique_user_job_application', + ), + ), + ] \ No newline at end of file diff --git a/api/jobs/models.py b/api/jobs/models.py index 7af40c8..6b9c13e 100644 --- a/api/jobs/models.py +++ b/api/jobs/models.py @@ -1,5 +1,7 @@ from django.db import models +from accounts.models import User + class StatusChoices(models.TextChoices): APPLIED = "APPLIED", "Applied" @@ -11,6 +13,12 @@ class StatusChoices(models.TextChoices): class JobApplication(models.Model): + user = models.ForeignKey( + User, + on_delete=models.CASCADE, + related_name="job_applications", + help_text="User who owns this job application.", + ) company_name = models.CharField(max_length=255) position_title = models.CharField(max_length=255) status = models.CharField( @@ -24,6 +32,12 @@ class JobApplication(models.Model): class Meta: ordering = ["-updated_at"] + constraints = [ + models.UniqueConstraint( + fields=["user", "company_name", "position_title"], + name="unique_user_job_application", + ) + ] def __str__(self) -> str: return f"{self.company_name} - {self.position_title}" @@ -57,4 +71,4 @@ class JobUpdate(models.Model): verbose_name_plural = "Job Updates" def __str__(self) -> str: - return f"Update #{self.id}: {self.job_application.company_name} → {self.to_status}" \ No newline at end of file + return f"Update #{self.id}: {self.job_application.company_name} -> {self.to_status}" \ No newline at end of file diff --git a/api/jobs/serializers.py b/api/jobs/serializers.py index 14703f7..468b003 100644 --- a/api/jobs/serializers.py +++ b/api/jobs/serializers.py @@ -3,6 +3,20 @@ from rest_framework import serializers from .models import JobApplication, JobUpdate +class SanitizedCharField(serializers.CharField): + """CharField that strips control characters on deserialization.""" + + def to_internal_value(self, data: object) -> object: + value = super().to_internal_value(data) + if isinstance(value, str): + value = value.strip() + if "\x00" in value: + raise serializers.ValidationError("Input contains invalid characters.") + import re + value = re.sub(r"[\x00-\x08\x0b\x0c\x0e-\x1f]", "", value) + return value + + class JobUpdateSerializer(serializers.ModelSerializer): company_name = serializers.CharField(source="job_application.company_name", read_only=True) position_title = serializers.CharField(source="job_application.position_title", read_only=True) @@ -24,10 +38,16 @@ class JobUpdateSerializer(serializers.ModelSerializer): class JobApplicationSerializer(serializers.ModelSerializer): + user_id = serializers.IntegerField(read_only=True) + notes = SanitizedCharField(required=False, allow_blank=True, default="") + company_name = SanitizedCharField(max_length=255) + position_title = SanitizedCharField(max_length=255) + class Meta: model = JobApplication fields = [ "id", + "user_id", "company_name", "position_title", "status", @@ -35,6 +55,11 @@ class JobApplicationSerializer(serializers.ModelSerializer): "created_at", "updated_at", ] + read_only_fields = ["user_id"] + + def create(self, validated_data): + validated_data["user"] = self.context["request"].user + return super().create(validated_data) class DashboardMetricsSerializer(serializers.Serializer): diff --git a/api/jobs/views.py b/api/jobs/views.py index a1f91c0..a094026 100644 --- a/api/jobs/views.py +++ b/api/jobs/views.py @@ -1,7 +1,7 @@ from django.db.models import Count, Q from rest_framework import viewsets -from rest_framework.permissions import IsAuthenticated from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated from rest_framework.request import Request from rest_framework.response import Response @@ -14,16 +14,30 @@ from .serializers import ( class JobApplicationViewSet(viewsets.ModelViewSet): - queryset = JobApplication.objects.all().prefetch_related("updates") serializer_class = JobApplicationSerializer permission_classes = [IsAuthenticated] + def get_queryset(self): + """Filter by user for non-admin users; admins see all.""" + user = self.request.user + qs = JobApplication.objects.all().prefetch_related("updates") + if not user.is_admin: + qs = qs.filter(user=user) + return qs + class JobUpdateViewSet(viewsets.ModelViewSet): - queryset = JobUpdate.objects.select_related("job_application").all() serializer_class = JobUpdateSerializer permission_classes = [IsAuthenticated] + def get_queryset(self): + """Filter by user via job_application for non-admin users.""" + user = self.request.user + qs = JobUpdate.objects.select_related("job_application").all() + if not user.is_admin: + qs = qs.filter(job_application__user=user) + return qs + @action(detail=False, methods=["get"]) def latest(self, request: Request) -> Response: """Return the 3 most recent job updates with job info and metrics.""" @@ -38,16 +52,24 @@ class JobUpdateViewSet(viewsets.ModelViewSet): @action(detail=False, methods=["get"]) def metrics(self, request: Request) -> Response: """Return dashboard metrics: total apps, status breakdown, interview/offer counts.""" - total = JobApplication.objects.count() + qs = self.get_queryset() + + total = qs.values("job_application").distinct().count() + # Get status counts from the distinct job applications the user owns + app_qs = JobApplication.objects.all() + if not request.user.is_admin: + app_qs = app_qs.filter(user=request.user) + + total = app_qs.count() status_counts = dict( - JobApplication.objects.values("status") + app_qs.values("status") .annotate(count=Count("id")) .values_list("status", "count") ) - interview_count = JobApplication.objects.filter( + interview_count = app_qs.filter( Q(status="INTERVIEW") | Q(status="OFFER") ).count() - offer_count = JobApplication.objects.filter(status="OFFER").count() + offer_count = app_qs.filter(status="OFFER").count() rejection_rate = ( round( status_counts.get("REJECTED", 0) / total * 100, 1 diff --git a/api/project/settings.py b/api/project/settings.py index 5748b5e..c845aef 100644 --- a/api/project/settings.py +++ b/api/project/settings.py @@ -22,6 +22,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", # Third-party "rest_framework", + "rest_framework_simplejwt.token_blacklist", "corsheaders", # Local "accounts", @@ -31,6 +32,7 @@ INSTALLED_APPS = [ MIDDLEWARE = [ "corsheaders.middleware.CorsMiddleware", "django.middleware.security.SecurityMiddleware", + "accounts.middleware.SecurityHeadersMiddleware", "django.contrib.sessions.middleware.SessionMiddleware", "django.middleware.common.CommonMiddleware", "django.middleware.csrf.CsrfViewMiddleware", @@ -65,7 +67,7 @@ _database_url = os.environ.get( "postgres://jobtracker:***@localhost:5432/jobtracker", ) -# postgres://user:***@host:port/dbname → django settings dict +# postgres://user:***@host:port/dbname -> django settings dict _db_parts = _database_url.replace("postgres://", "").split("@") _credentials, _host_db = _db_parts[0], _db_parts[1] _db_user, _db_pass = _credentials.split(":") @@ -91,26 +93,45 @@ REST_FRAMEWORK = { "rest_framework_simplejwt.authentication.JWTAuthentication", ), "DEFAULT_PERMISSION_CLASSES": ( - "rest_framework.permissions.AllowAny", + "rest_framework.permissions.IsAuthenticated", ), "DEFAULT_RENDERER_CLASSES": ( "rest_framework.renderers.JSONRenderer", ), + "EXCEPTION_HANDLER": "accounts.exceptions.api_exception_handler", "DEFAULT_THROTTLE_CLASSES": [ "rest_framework.throttling.AnonRateThrottle", + "accounts.throttles.UserRateThrottle", ], "DEFAULT_THROTTLE_RATES": { "anon": os.environ.get("DJANGO_THROTTLE_ANON_RATE", "10/hour"), "auth": os.environ.get("DJANGO_THROTTLE_AUTH_RATE", "5/minute"), + "user": os.environ.get("DJANGO_THROTTLE_USER_RATE", "60/minute"), }, } SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(hours=24), - "REFRESH_TOKEN_LIFETIME": timedelta(days=30), + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=15), + "REFRESH_TOKEN_LIFETIME": timedelta(days=7), + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, "AUTH_HEADER_TYPES": ("Bearer",), } +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { + "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", + "OPTIONS": {"min_length": 8}, + }, + { + "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", + }, + { + "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", + }, +] + # CORS CORS_ALLOW_ALL_ORIGINS = os.environ.get("CORS_ALLOW_ALL_ORIGINS", "False").lower() in ("true", "1", "yes") CORS_ALLOWED_ORIGINS = os.environ.get( @@ -125,4 +146,4 @@ USE_TZ = True STATIC_URL = "/static/" -DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" \ No newline at end of file diff --git a/docs/backend/api-security-phase-1.md b/docs/backend/api-security-phase-1.md new file mode 100644 index 0000000..2d6fde8 --- /dev/null +++ b/docs/backend/api-security-phase-1.md @@ -0,0 +1,186 @@ +# API Security Phase 1 — Robust Endpoint Protection + +**Issue:** crisleo-hermes/job-tracker#7 +**Feature branch:** feature/api-security-phase-1 +**Date:** 2026-05-27 + +--- + +## Overview + +Implement comprehensive API security measures: short-lived JWT with refresh token rotation, token blacklisting, role-based access control, rate limiting tightening, input sanitization, a custom exception handler that suppresses stack traces, and minimal sensitive data exposure in API responses. + +--- + +## Backend Specification + +### 1. Token Lifecycle Changes + +#### Shortened JWT Lifetimes + +| Setting | Current | New | Rationale | +|---|---|---|---| +| `ACCESS_TOKEN_LIFETIME` | 24 hours | 15 minutes | Minimises window for stolen tokens | +| `REFRESH_TOKEN_LIFETIME` | 30 days | 7 days | Limits refresh token exposure | +| `ROTATE_REFRESH_TOKENS` | (not set) | `True` | Old refresh token invalidated on each refresh | +| `BLACKLIST_AFTER_ROTATION` | (not set) | `True` | Used refresh tokens cannot be replayed | + +#### Token Blacklist Model + +Add a `BlacklistedToken` model storing the JWT `jti` (JWT ID claim), the user, and the expiry datetime. This allows explicit logout (invalidate current token) and ensures rotated refresh tokens cannot be reused. + +**Model: `accounts.BlacklistedToken`** + +| Field | Type | Notes | +|---|---|---| +| `jti` | `UUIDField(unique=True)` | JWT ID from the token payload | +| `user` | `ForeignKey(User)` | Owner of the token | +| `created_at` | `DateTimeField(auto_now_add=True)` | When it was blacklisted | +| `expires_at` | `DateTimeField()` | When the token would have naturally expired | + +A management command (`cleartokens`) purges expired entries. + +#### New API Endpoints + +**`POST /api/auth/logout/`** — Blacklist the current refresh token. + +**Request:** +```json +{ "refresh": "" } +``` + +**Validation:** +- Refresh token must be valid (not expired, not already blacklisted) +- Returns 205 Reset Content on success (204 would also be acceptable; 205 signals the client should reset its state) + +**`POST /api/auth/refresh/`** — Override of SimpleJWT's built-in refresh with rotation + blacklist. + +Same shape as SimpleJWT default — accepts `{ "refresh": "" }`, returns `{ "access": "...", "refresh": "..." }`. + +**`GET /api/auth/me/`** — Return the current user's profile. + +Requires valid access token. Returns: +```json +{ "id": 1, "email": "user@example.com", "first_name": "John", "last_name": "Doe" } +``` + +### 2. Role-Based Access Control (RBAC) + +Two built-in roles via Django's `Groups`: + +| Role | Permissions | +|---|---| +| `admin` | Full CRUD on all resources | +| `user` (default) | CRUD on own resources only | + +No separate `Role` model — reuse Django's built-in `django.contrib.auth.models.Group`. + +#### Permission Logic + +- `IsAdminOrReadOnly` — admin can do anything; authenticated users can read; anonymous denied +- `IsOwnerOrAdmin` — object-level permission: user can modify their own resources; admin can modify anything + +#### Model Ownership + +Add `user = ForeignKey(User, on_delete=CASCADE, related_name="job_applications")` to `JobApplication`. This is a **schema change** — existing records get a default of the first superuser (migration handles this). + +Filter queries by `request.user` for non-admin users. Admins see all records. + +### 3. Custom Exception Handler + +Create `accounts.exceptions` module with a DRF custom exception handler that: + +- Returns `{"error": "message", "code": "error_code"}` for all API errors +- NEVER includes stack traces, file paths, or Python internals +- Maps DRF's built-in exceptions to user-safe messages +- Logs the full traceback to `django.log` (sensitive details go to logs, not to the client) +- Returns 500 with `{"error": "Internal server error.", "code": "internal_error"}` for unhandled exceptions + +### 4. Input Validation & Sanitization + +**Current state:** `RegisterSerializer` already validates email format, password length, and password match. + +**Additions:** +- Strip leading/trailing whitespace from all string fields across all serializers (`JobApplicationSerializer`, `JobUpdateSerializer`) +- Reject null bytes (`\x00`) in string inputs (null-byte injection protection) +- Add a reusable `SanitizedCharField` that strips control characters (ASCII 0x00–0x1F except `\t`, `\n`, `\r`) on deserialization +- Validate `company_name` and `position_title` max lengths (already on model, enforce in serializer) + +### 5. Rate Limiting Enhancements + +| Scope | Rate | Applied to | +|---|---|---| +| `anon` | `10/hour` | All anonymous endpoints | +| `auth` | `5/minute` | Login, Register (already implemented) | +| `user` (new) | `60/minute` | All authenticated endpoints | + +Implement a custom `UserRateThrottle` that scopes by user ID for authenticated requests. + +### 6. Security Middleware + +**`accounts.middleware.SecurityHeadersMiddleware`** + +Adds response headers: +- `X-Content-Type-Options: nosniff` +- `X-Frame-Options: DENY` +- `Referrer-Policy: strict-origin-when-cross-origin` +- `Permissions-Policy: geolocation=(), microphone=(), camera=()` +- `Strict-Transport-Security: max-age=31536000; includeSubDomains` (only when not DEBUG) + +### 7. Sensitive Data Exposure + +- `UserSerializer` already only exposes `id`, `email`, `first_name`, `last_name` ❌ +- Remove `email` from `UserSerializer` — use `id`, `first_name`, `last_name` only (email is PII) +- In `JobApplicationSerializer`, exclude `notes` from list responses (only include in detail) +- Add `user_id` to `JobApplicationSerializer` (read-only, set automatically on create) + +--- + +## Frontend Specification + +### Token Refresh Interceptor + +Add an Axios response interceptor that: +1. Detects 401 responses +2. Tries `POST /api/auth/refresh/` with the stored refresh token +3. On success: replaces `access_token` in localStorage, retries the original request +4. On failure: clears tokens, redirects to `/login` + +### Protected Route Component + +Create `` component that: +- Reads `isAuthenticated` from `AuthContext` +- If not authenticated: redirects to `/login` with a `?redirect=` param +- If loading: shows a loading spinner +- If authenticated: renders children + +### Logout in AuthContext + +Add a `logout()` call that: +- Calls `POST /api/auth/logout/` with the stored refresh token +- Clears tokens from localStorage +- Dispatches `LOGOUT` action +- Redirects to `/login` + +### Auth Flow Updates + +- `LoginPage` / `RegisterPage`: redirect authenticated users to `/` via `Navigate` (already handled via `AuthContext`) +- HomePage: no change needed — it already uses `useAuth` and the API client attaches the token + +--- + +## Migration Notes + +1. `accounts` app: add `BlacklistedToken` model → `0002_blacklistedtoken` +2. `jobs` app: add `user` FK to `JobApplication` → `0002_jobapplication_user` + - Existing rows: assign to the first superuser via `RunPython` migration + - New `unique_together = ["user", "company_name", "position_title"]` + +--- + +## Security Considerations (Phase 1 Gaps) + +- No API key for machine-to-machine access (deferred to Phase 2) +- No rate limiting on `/api/auth/refresh/` (low risk — refresh tokens are single-use with rotation) +- No CSRF protection for cookie-based auth (not applicable — JWT Bearer auth only) +- Logging configuration deferred to deployment (Phase 2) diff --git a/web/src/App.tsx b/web/src/App.tsx index 365c1db..fade614 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,23 +1,97 @@ import { type ReactNode } from "react"; -import { BrowserRouter, Routes, Route } from "react-router-dom"; -import { AuthProvider } from "./contexts/AuthContext"; +import { BrowserRouter, Routes, Route, Navigate, useLocation } from "react-router-dom"; +import { AuthProvider, useAuth } from "./contexts/AuthContext"; import AppLayout from "./components/AppLayout"; import HomePage from "./pages/HomePage"; import LoginPage from "./pages/LoginPage"; import RegisterPage from "./pages/RegisterPage"; +function ProtectedRoute({ children }: { children: ReactNode }): ReactNode { + const { state } = useAuth(); + const location = useLocation(); + + if (state.isInitializing) { + return ( +
+ Loading... +
+ ); + } + + if (!state.isAuthenticated) { + return ; + } + + return children; +} + +function PublicRoute({ children }: { children: ReactNode }): ReactNode { + const { state } = useAuth(); + + if (state.isInitializing) { + return ( +
+ Loading... +
+ ); + } + + if (state.isAuthenticated) { + return ; + } + + return children; +} + export default function App(): ReactNode { return ( + + + + } + /> + + + + } + /> }> - } /> - } /> - } /> + + +
+ } + /> ); -} +} \ No newline at end of file diff --git a/web/src/components/AppLayout.tsx b/web/src/components/AppLayout.tsx index bb0f9fc..583c36f 100644 --- a/web/src/components/AppLayout.tsx +++ b/web/src/components/AppLayout.tsx @@ -1,12 +1,14 @@ import { type ReactNode } from "react"; -import { Outlet } from "react-router-dom"; +import { Outlet, useNavigate } from "react-router-dom"; import AppBar from "@mui/material/AppBar"; import Toolbar from "@mui/material/Toolbar"; import Typography from "@mui/material/Typography"; import Container from "@mui/material/Container"; import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; import CssBaseline from "@mui/material/CssBaseline"; import { ThemeProvider, createTheme } from "@mui/material/styles"; +import { useAuth } from "../contexts/AuthContext"; const theme = createTheme({ palette: { @@ -20,15 +22,28 @@ const theme = createTheme({ }); export default function AppLayout(): ReactNode { + const { state, logout } = useAuth(); + const navigate = useNavigate(); + + const handleLogout = async () => { + await logout(); + navigate("/login"); + }; + return ( - + Job Tracker + {state.isAuthenticated && ( + + )} diff --git a/web/src/contexts/AuthContext.tsx b/web/src/contexts/AuthContext.tsx index 4daf8d1..599119d 100644 --- a/web/src/contexts/AuthContext.tsx +++ b/web/src/contexts/AuthContext.tsx @@ -3,12 +3,18 @@ import { useContext, useReducer, useCallback, + useEffect, type ReactNode, type Dispatch, } from "react"; import { loginUser, registerUser, + getProfile, + logoutUser, + setTokens, + clearTokens, + refreshAccessToken, type UserProfile, type LoginPayload, type RegisterPayload, @@ -19,6 +25,7 @@ interface AuthState { user: UserProfile | null; isAuthenticated: boolean; isLoading: boolean; + isInitializing: boolean; error: string | null; } @@ -26,6 +33,7 @@ const initialState: AuthState = { user: null, isAuthenticated: false, isLoading: false, + isInitializing: true, error: null, }; @@ -35,6 +43,7 @@ type AuthAction = | { type: "AUTH_SUCCESS"; payload: UserProfile } | { type: "AUTH_FAILURE"; payload: string } | { type: "LOGOUT" } + | { type: "INIT_COMPLETE" } | { type: "CLEAR_ERROR" }; function authReducer(state: AuthState, action: AuthAction): AuthState { @@ -45,6 +54,7 @@ function authReducer(state: AuthState, action: AuthAction): AuthState { return { ...state, isLoading: false, + isInitializing: false, isAuthenticated: true, user: action.payload, error: null, @@ -56,7 +66,9 @@ function authReducer(state: AuthState, action: AuthAction): AuthState { error: action.payload, }; case "LOGOUT": - return { ...initialState }; + return { ...initialState, isInitializing: false }; + case "INIT_COMPLETE": + return { ...state, isInitializing: false }; case "CLEAR_ERROR": return { ...state, error: null }; default: @@ -70,7 +82,7 @@ interface AuthContextValue { dispatch: Dispatch; login: (payload: LoginPayload) => Promise; register: (payload: RegisterPayload) => Promise; - logout: () => void; + logout: () => Promise; clearError: () => void; } @@ -80,12 +92,38 @@ const AuthContext = createContext(null); export function AuthProvider({ children }: { children: ReactNode }) { const [state, dispatch] = useReducer(authReducer, initialState); + // On mount: try to refresh the access token and fetch user profile + useEffect(() => { + const initAuth = async () => { + const refreshToken = localStorage.getItem("refresh_token"); + if (!refreshToken) { + dispatch({ type: "INIT_COMPLETE" }); + return; + } + + try { + // Try refreshing the access token first + const tokens = await refreshAccessToken(refreshToken); + setTokens(tokens.access, tokens.refresh); + + // Fetch user profile with the fresh token + const profile = await getProfile(); + dispatch({ type: "AUTH_SUCCESS", payload: profile }); + } catch { + // Token invalid or expired — clear everything + clearTokens(); + dispatch({ type: "LOGOUT" }); + } + }; + + initAuth(); + }, []); + const login = useCallback(async (payload: LoginPayload) => { dispatch({ type: "AUTH_START" }); try { const response = await loginUser(payload); - localStorage.setItem("access_token", response.access); - localStorage.setItem("refresh_token", response.refresh); + setTokens(response.access, response.refresh); dispatch({ type: "AUTH_SUCCESS", payload: response.user }); } catch (err: unknown) { const message = @@ -100,7 +138,6 @@ export function AuthProvider({ children }: { children: ReactNode }) { try { await registerUser(payload); // Registration succeeded — the page component handles redirect to /login. - // Set isLoading=false by clearing auth state (no auto-authentication). dispatch({ type: "LOGOUT" }); } catch (err: unknown) { const message = @@ -112,9 +149,16 @@ export function AuthProvider({ children }: { children: ReactNode }) { } }, []); - const logout = useCallback(() => { - localStorage.removeItem("access_token"); - localStorage.removeItem("refresh_token"); + const logout = useCallback(async () => { + const refreshToken = localStorage.getItem("refresh_token"); + if (refreshToken) { + try { + await logoutUser(refreshToken); + } catch { + // Even if the server request fails, clear local state + } + } + clearTokens(); dispatch({ type: "LOGOUT" }); }, []); @@ -138,4 +182,4 @@ export function useAuth(): AuthContextValue { throw new Error("useAuth must be used within an AuthProvider"); } return context; -} +} \ No newline at end of file diff --git a/web/src/hooks/useDashboardData.ts b/web/src/hooks/useDashboardData.ts index 0fa064b..de4289a 100644 --- a/web/src/hooks/useDashboardData.ts +++ b/web/src/hooks/useDashboardData.ts @@ -11,9 +11,20 @@ interface UseDashboardDataResult { const API_BASE = "/api"; async function fetchJson(url: string): Promise { - const response = await fetch(url); + const token = localStorage.getItem("access_token"); + const headers: Record = { + "Content-Type": "application/json", + }; + if (token) { + headers["Authorization"] = `Bearer ${token}`; + } + const response = await fetch(url, { headers }); if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); + const body = await response.json().catch(() => ({})); + throw new Error( + (body as { error?: string }).error || + `HTTP ${response.status}: ${response.statusText}` + ); } return response.json() as Promise; } diff --git a/web/src/services/authApi.ts b/web/src/services/authApi.ts index a6a0dd5..9c19f79 100644 --- a/web/src/services/authApi.ts +++ b/web/src/services/authApi.ts @@ -16,6 +16,97 @@ apiClient.interceptors.request.use((config) => { return config; }); +// Response interceptor: auto-refresh on 401, retry once +let isRefreshing = false; +let failedQueue: Array<{ + resolve: (token: string) => void; + reject: (err: unknown) => void; +}> = []; + +function processQueue(error: unknown, token: string | null = null): void { + failedQueue.forEach((prom) => { + if (error) { + prom.reject(error); + } else if (token) { + prom.resolve(token); + } + }); + failedQueue = []; +} + +apiClient.interceptors.response.use( + (response) => response, + async (error) => { + const originalRequest = error.config; + + // Only handle 401s that aren't already refresh/login/register attempts + if ( + error.response?.status !== 401 || + originalRequest._retry || + originalRequest.url?.includes("/api/auth/token/refresh/") || + originalRequest.url?.includes("/api/auth/login/") || + originalRequest.url?.includes("/api/auth/register/") || + originalRequest.url?.includes("/api/auth/logout/") + ) { + return Promise.reject(error); + } + + if (isRefreshing) { + // Queue this request until the refresh completes + return new Promise((resolve, reject) => { + failedQueue.push({ resolve, reject }); + }).then((token) => { + originalRequest.headers.Authorization = `Bearer ${token}`; + return apiClient(originalRequest); + }); + } + + originalRequest._retry = true; + isRefreshing = true; + + const refreshToken = localStorage.getItem("refresh_token"); + + if (!refreshToken) { + isRefreshing = false; + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + // Redirect to login + window.location.href = "/login"; + return Promise.reject(error); + } + + try { + const response = await axios.post( + `${ + import.meta.env.VITE_API_URL || "http://localhost:8000" + }/api/auth/token/refresh/`, + { refresh: refreshToken } + ); + + const newAccessToken = response.data.access; + const newRefreshToken = response.data.refresh; + + localStorage.setItem("access_token", newAccessToken); + localStorage.setItem("refresh_token", newRefreshToken); + + processQueue(null, newAccessToken); + + originalRequest.headers.Authorization = `Bearer ${newAccessToken}`; + return apiClient(originalRequest); + } catch (refreshError) { + processQueue(refreshError, null); + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); + window.location.href = "/login"; + return Promise.reject(refreshError); + } finally { + isRefreshing = false; + } + } +); + +// ── Types ────────────────────────────────────────────────────────────── + export interface RegisterPayload { email: string; password: string; @@ -31,7 +122,6 @@ export interface LoginPayload { export interface UserProfile { id: number; - email: string; first_name: string; last_name: string; } @@ -42,6 +132,8 @@ export interface LoginResponse { refresh: string; } +// ── API Functions ────────────────────────────────────────────────────── + export function registerUser(payload: RegisterPayload): Promise { return apiClient .post("/api/auth/register/", payload) @@ -53,3 +145,36 @@ export function loginUser(payload: LoginPayload): Promise { .post("/api/auth/login/", payload) .then((res) => res.data); } + +export function getProfile(): Promise { + return apiClient + .get("/api/auth/me/") + .then((res) => res.data); +} + +export function logoutUser(refreshToken: string): Promise { + return apiClient + .post("/api/auth/logout/", { refresh: refreshToken }) + .then(() => {}); +} + +export function refreshAccessToken( + refreshToken: string +): Promise<{ access: string; refresh: string }> { + return apiClient + .post<{ access: string; refresh: string }>( + "/api/auth/token/refresh/", + { refresh: refreshToken } + ) + .then((res) => res.data); +} + +export function setTokens(access: string, refresh: string): void { + localStorage.setItem("access_token", access); + localStorage.setItem("refresh_token", refresh); +} + +export function clearTokens(): void { + localStorage.removeItem("access_token"); + localStorage.removeItem("refresh_token"); +} \ No newline at end of file