Author SHA1 Message Date
Marko (Hermes Implementer) c2fc7bc15f feat: implement API security measures phase 1 2026-05-27 00:24:09 +00:00
24 changed files with 1018 additions and 457 deletions
+11 -1
View File
@@ -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)
@@ -37,3 +37,13 @@ class UserAdmin(BaseUserAdmin):
list_display = ("email", "first_name", "last_name", "is_staff")
search_fields = ("email", "first_name", "last_name")
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",)
+149
View File
@@ -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")
+24
View File
@@ -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
@@ -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')],
},
),
]
+30
View File
@@ -27,3 +27,33 @@ class User(AbstractUser):
def __str__(self) -> str:
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})"
+33
View File
@@ -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
+48 -7
View File
@@ -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")
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()
+12
View File
@@ -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
+5 -2
View File
@@ -1,5 +1,5 @@
from django.urls import path
from rest_framework_simplejwt.views import TokenRefreshView
from rest_framework_simplejwt.views import TokenVerifyView
from accounts import views
@@ -8,5 +8,8 @@ app_name = "accounts"
urlpatterns = [
path("register/", views.register_view, name="register"),
path("login/", views.login_view, name="login"),
path("token/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
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"),
]
+73 -1
View File
@@ -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):
@@ -47,3 +55,67 @@ def login_view(request: Request) -> Response:
},
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,
)
@@ -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',
),
),
]
+15 -1
View File
@@ -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}"
return f"Update #{self.id}: {self.job_application.company_name} -> {self.to_status}"
+25
View File
@@ -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):
+29 -7
View File
@@ -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
+25 -4
View File
@@ -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(
-36
View File
@@ -222,39 +222,3 @@ Axios-based HTTP client with:
- User emails normalized to lowercase before storage
- JWT tokens stored in localStorage (trade-off: simple SPA integration vs XSS exposure — refresh tokens mitigate short exposure)
- `AUTH_HEADER_TYPES` set to `("Bearer",)` following RFC 6750
---
## Post-Merge Fixes (PR #14)
**Issue:** Issue #3 was re-opened after initial merge. Root cause: frontend error handling was using `err.message` from AxiosError objects, which produces `"Request failed with status code 400"` instead of the actual backend validation errors.
### Changes
| Area | Change | Detail |
|---|---|---|
| `api/accounts/urls.py` | Added `token/refresh/` endpoint | Maps to `rest_framework_simplejwt.views.TokenRefreshView` |
| `web/src/services/authApi.ts` | **Error extraction** | New `extractErrorMessage()` function parses `err.response.data` — handles `non_field_errors`, `detail`, and field-level error arrays |
| `web/src/services/authApi.ts` | **Token refresh interceptor** | Axios response interceptor intercepts 401, refreshes access token using stored refresh_token, replays queued requests |
| `web/src/services/authApi.ts` | Export `setTokens`, `clearTokens`, `getAccessToken` | Shared token utility functions for AuthContext |
| `web/src/contexts/AuthContext.tsx` | **Fixed error handling** | Uses `extractErrorMessage(err)` instead of `err instanceof Error ? err.message : ...` |
| `web/src/contexts/AuthContext.tsx` | **Auth state restoration** | `RESTORE_COMPLETE` action; `isRestoring` flag prevents protected route flash on page refresh |
| `web/src/contexts/AuthContext.tsx` | **Persist user data** | Stores `user_data` in localStorage alongside tokens; restores `UserProfile` on page reload |
| `web/src/components/ProtectedRoute.tsx` | New component | Route guard — redirects to `/login` if unauthenticated; shows loading spinner during restoration |
| `web/src/App.tsx` | Route restructure | Public routes (`/login`, `/register`) outside ProtectedRoute; protected routes (`/`) inside it |
### Error handling flow
1. Backend returns `{"email": ["A user with this email already exists."]}` (DRF standard)
2. Axios interceptor catches non-401 responses, passes error through
3. `extractErrorMessage()` iterates `response.data` keys, returns first error string
4. AuthContext dispatches `AUTH_FAILURE` with extracted message
5. LoginPage/RegisterPage renders `state.error` — user sees: "A user with this email already exists."
### Token refresh flow
1. User logs in — `access_token` and `refresh_token` stored in localStorage
2. When access token expires, next API call receives 401
3. Response interceptor catches it, calls `POST /api/auth/token/refresh/`
4. On success — new access token stored, original request retried, queued requests replayed
5. On failure — tokens cleared, all pending requests rejected with "Session expired"
+186
View File
@@ -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": "<refresh_token>" }
```
**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": "<token>" }`, 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 0x000x1F 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 `<ProtectedRoute>` 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)
+79 -11
View File
@@ -1,26 +1,94 @@
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 ProtectedRoute from "./components/ProtectedRoute";
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 (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
color: "#888",
}}
>
Loading...
</div>
);
}
if (!state.isAuthenticated) {
return <Navigate to="/login" state={{ from: location }} replace />;
}
return children;
}
function PublicRoute({ children }: { children: ReactNode }): ReactNode {
const { state } = useAuth();
if (state.isInitializing) {
return (
<div
style={{
display: "flex",
justifyContent: "center",
alignItems: "center",
height: "100vh",
color: "#888",
}}
>
Loading...
</div>
);
}
if (state.isAuthenticated) {
return <Navigate to="/" replace />;
}
return children;
}
export default function App(): ReactNode {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
{/* Protected routes (require authentication) */}
<Route element={<ProtectedRoute />}>
<Route
path="/login"
element={
<PublicRoute>
<LoginPage />
</PublicRoute>
}
/>
<Route
path="/register"
element={
<PublicRoute>
<RegisterPage />
</PublicRoute>
}
/>
<Route element={<AppLayout />}>
<Route index element={<HomePage />} />
</Route>
<Route
index
element={
<ProtectedRoute>
<HomePage />
</ProtectedRoute>
}
/>
</Route>
</Routes>
</AuthProvider>
+17 -2
View File
@@ -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 (
<ThemeProvider theme={theme}>
<CssBaseline />
<Box sx={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}>
<AppBar position="sticky">
<Toolbar>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700 }}>
<Typography variant="h6" component="h1" sx={{ fontWeight: 700, flexGrow: 1 }}>
Job Tracker
</Typography>
{state.isAuthenticated && (
<Button color="inherit" onClick={handleLogout}>
Sign Out
</Button>
)}
</Toolbar>
</AppBar>
<Container component="main" maxWidth="lg" sx={{ mt: 4, mb: 4, flexGrow: 1 }}>
-49
View File
@@ -1,49 +0,0 @@
import { type ReactNode } from "react";
import { Navigate, Outlet, useLocation } from "react-router-dom";
import { useAuth } from "../contexts/AuthContext";
import CircularProgress from "@mui/material/CircularProgress";
import Box from "@mui/material/Box";
interface ProtectedRouteProps {
/** Optional redirect path for unauthenticated users (default: /login) */
redirectTo?: string;
/** Optional fallback UI while restoring auth state */
fallback?: ReactNode;
}
/**
* Route guard that redirects unauthenticated users to the login page.
* Shows a loading spinner while auth state is being restored from localStorage.
*/
export default function ProtectedRoute({
redirectTo = "/login",
fallback,
}: ProtectedRouteProps): ReactNode {
const { state } = useAuth();
const location = useLocation();
// Still checking localStorage for existing session
if (state.isRestoring) {
return (
fallback ?? (
<Box
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
minHeight: "100vh",
}}
>
<CircularProgress />
</Box>
)
);
}
if (!state.isAuthenticated) {
// Preserve the attempted URL so we can redirect back after login
return <Navigate to={redirectTo} state={{ from: location }} replace />;
}
return <Outlet />;
}
+48 -41
View File
@@ -10,10 +10,11 @@ import {
import {
loginUser,
registerUser,
extractErrorMessage,
getProfile,
logoutUser,
setTokens,
clearTokens,
getAccessToken,
refreshAccessToken,
type UserProfile,
type LoginPayload,
type RegisterPayload,
@@ -24,7 +25,7 @@ interface AuthState {
user: UserProfile | null;
isAuthenticated: boolean;
isLoading: boolean;
isRestoring: boolean;
isInitializing: boolean;
error: string | null;
}
@@ -32,7 +33,7 @@ const initialState: AuthState = {
user: null,
isAuthenticated: false,
isLoading: false,
isRestoring: true, // starts true until we check localStorage
isInitializing: true,
error: null,
};
@@ -42,8 +43,8 @@ type AuthAction =
| { type: "AUTH_SUCCESS"; payload: UserProfile }
| { type: "AUTH_FAILURE"; payload: string }
| { type: "LOGOUT" }
| { type: "CLEAR_ERROR" }
| { type: "RESTORE_COMPLETE"; payload: UserProfile | null };
| { type: "INIT_COMPLETE" }
| { type: "CLEAR_ERROR" };
function authReducer(state: AuthState, action: AuthAction): AuthState {
switch (action.type) {
@@ -53,7 +54,7 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
return {
...state,
isLoading: false,
isRestoring: false,
isInitializing: false,
isAuthenticated: true,
user: action.payload,
error: null,
@@ -62,20 +63,14 @@ function authReducer(state: AuthState, action: AuthAction): AuthState {
return {
...state,
isLoading: false,
isRestoring: false,
error: action.payload,
};
case "LOGOUT":
return { ...initialState, isRestoring: false };
return { ...initialState, isInitializing: false };
case "INIT_COMPLETE":
return { ...state, isInitializing: false };
case "CLEAR_ERROR":
return { ...state, error: null };
case "RESTORE_COMPLETE":
return {
...state,
isRestoring: false,
isAuthenticated: action.payload !== null,
user: action.payload,
};
default:
return state;
}
@@ -87,7 +82,7 @@ interface AuthContextValue {
dispatch: Dispatch<AuthAction>;
login: (payload: LoginPayload) => Promise<void>;
register: (payload: RegisterPayload) => Promise<void>;
logout: () => void;
logout: () => Promise<void>;
clearError: () => void;
}
@@ -97,28 +92,31 @@ const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: ReactNode }) {
const [state, dispatch] = useReducer(authReducer, initialState);
// Restore auth state from localStorage on mount
// On mount: try to refresh the access token and fetch user profile
useEffect(() => {
const token = getAccessToken();
if (token) {
// We have a stored token — try to validate it by fetching the user profile.
// For now, assume the token is valid if it exists. A full implementation
// would call a /api/auth/me/ endpoint to verify the token.
// Since we don't have that endpoint, we'll set isRestoring=false and let
// the user see the authenticated UI. API calls will fail at runtime if the
// token is expired (and the refresh interceptor handles that).
const userData = localStorage.getItem("user_data");
if (userData) {
try {
const user: UserProfile = JSON.parse(userData);
dispatch({ type: "AUTH_SUCCESS", payload: user });
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 {
// corrupt stored data — proceed with unauthenticated state
// Token invalid or expired — clear everything
clearTokens();
dispatch({ type: "LOGOUT" });
}
}
}
dispatch({ type: "RESTORE_COMPLETE", payload: null });
};
initAuth();
}, []);
const login = useCallback(async (payload: LoginPayload) => {
@@ -126,10 +124,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const response = await loginUser(payload);
setTokens(response.access, response.refresh);
localStorage.setItem("user_data", JSON.stringify(response.user));
dispatch({ type: "AUTH_SUCCESS", payload: response.user });
} catch (err: unknown) {
const message = extractErrorMessage(err);
const message =
err instanceof Error ? err.message : "Login failed. Please try again.";
dispatch({ type: "AUTH_FAILURE", payload: message });
throw err;
}
@@ -140,18 +138,27 @@ 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 = extractErrorMessage(err);
const message =
err instanceof Error
? err.message
: "Registration failed. Please try again.";
dispatch({ type: "AUTH_FAILURE", payload: message });
throw err;
}
}, []);
const logout = useCallback(() => {
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();
localStorage.removeItem("user_data");
dispatch({ type: "LOGOUT" });
}, []);
+13 -2
View File
@@ -11,9 +11,20 @@ interface UseDashboardDataResult {
const API_BASE = "/api";
async function fetchJson<T>(url: string): Promise<T> {
const response = await fetch(url);
const token = localStorage.getItem("access_token");
const headers: Record<string, string> = {
"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<T>;
}
+94 -117
View File
@@ -1,13 +1,4 @@
import axios, { AxiosError, type AxiosResponse, type InternalAxiosRequestConfig } from "axios";
interface QueuedRequest {
resolve: (token: string) => void;
reject: (err: unknown) => void;
}
interface RetryConfig extends InternalAxiosRequestConfig {
_retry?: boolean;
}
import axios from "axios";
const apiClient = axios.create({
baseURL: import.meta.env.VITE_API_URL || "http://localhost:8000",
@@ -16,64 +7,56 @@ const apiClient = axios.create({
},
});
// ── Token management ──────────────────────────────────────────────────
function getAccessToken(): string | null {
return localStorage.getItem("access_token");
}
function getRefreshToken(): string | null {
return localStorage.getItem("refresh_token");
}
function setTokens(access: string, refresh: string): void {
localStorage.setItem("access_token", access);
localStorage.setItem("refresh_token", refresh);
}
function clearTokens(): void {
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
}
// ── Request interceptor: attach access token ──────────────────────────
apiClient.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = getAccessToken();
// Attach access token to every request if present
apiClient.interceptors.request.use((config) => {
const token = localStorage.getItem("access_token");
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// ── Response interceptor: auto-refresh on 401 ─────────────────────────
// Response interceptor: auto-refresh on 401, retry once
let isRefreshing = false;
let pendingRequests: QueuedRequest[] = [];
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: AxiosResponse) => response,
async (error: AxiosError) => {
const originalRequest = error.config as RetryConfig | undefined;
(response) => response,
async (error) => {
const originalRequest = error.config;
// Only attempt refresh if it's a 401, not already retried, and we have a refresh token
// Only handle 401s that aren't already refresh/login/register attempts
if (
!originalRequest ||
error.response?.status !== 401 ||
originalRequest._retry ||
!getRefreshToken()
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 already refreshing, queue this request
if (isRefreshing) {
// Queue this request until the refresh completes
return new Promise<string>((resolve, reject) => {
pendingRequests.push({ resolve, reject });
failedQueue.push({ resolve, reject });
}).then((token) => {
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${token}`;
}
return apiClient(originalRequest);
});
}
@@ -81,84 +64,48 @@ apiClient.interceptors.response.use(
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(
`${apiClient.defaults.baseURL}/api/auth/token/refresh/`,
{ refresh: getRefreshToken() },
`${
import.meta.env.VITE_API_URL || "http://localhost:8000"
}/api/auth/token/refresh/`,
{ refresh: refreshToken }
);
const newAccess: string = response.data.access;
localStorage.setItem("access_token", newAccess);
// Replay queued requests with the new token
pendingRequests.forEach((p) => p.resolve(newAccess));
pendingRequests = [];
const newAccessToken = response.data.access;
const newRefreshToken = response.data.refresh;
if (originalRequest.headers) {
originalRequest.headers.Authorization = `Bearer ${newAccess}`;
}
localStorage.setItem("access_token", newAccessToken);
localStorage.setItem("refresh_token", newRefreshToken);
processQueue(null, newAccessToken);
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
return apiClient(originalRequest);
} catch {
// Refresh failed — clear tokens and reject all queued requests
clearTokens();
pendingRequests.forEach((p) =>
p.reject(new Error("Session expired. Please sign in again.")),
);
pendingRequests = [];
return Promise.reject(error);
} catch (refreshError) {
processQueue(refreshError, null);
localStorage.removeItem("access_token");
localStorage.removeItem("refresh_token");
window.location.href = "/login";
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
}
);
// ── Error extraction from Axios responses ─────────────────────────────
/**
* Extract a human-readable error message from an AxiosError.
* Backend DRF errors can be:
* - {"field": ["message"]} (field-level)
* - {"non_field_errors": ["message"]} (general)
* - {"detail": "message"} (list/generic)
* - string (unexpected shape)
*/
export function extractErrorMessage(err: unknown): string {
if (err instanceof AxiosError && err.response?.data) {
const data = err.response.data as Record<string, unknown>;
// DRF non_field_errors
if (
Array.isArray(data.non_field_errors) &&
data.non_field_errors.length > 0
) {
return String(data.non_field_errors[0]);
}
// DRF detail (e.g., 401 Unauthorized)
if (typeof data.detail === "string") {
return data.detail;
}
// Field-level errors — pick the first one
for (const key of Object.keys(data)) {
const val = data[key];
if (Array.isArray(val) && val.length > 0) {
return String(val[0]);
}
if (typeof val === "string") {
return val;
}
}
// Fallback: raw string
if (typeof data === "string") return data;
}
// Generic error fallback
if (err instanceof Error) return err.message;
return "An unexpected error occurred. Please try again.";
}
// ── Types ─────────────────────────────────────────────────────────────
// ── Types ──────────────────────────────────────────────────────────────
export interface RegisterPayload {
email: string;
@@ -175,7 +122,6 @@ export interface LoginPayload {
export interface UserProfile {
id: number;
email: string;
first_name: string;
last_name: string;
}
@@ -186,7 +132,7 @@ export interface LoginResponse {
refresh: string;
}
// ── API functions ─────────────────────────────────────────────────────
// ── API Functions ─────────────────────────────────────────────────────
export function registerUser(payload: RegisterPayload): Promise<UserProfile> {
return apiClient
@@ -200,4 +146,35 @@ export function loginUser(payload: LoginPayload): Promise<LoginResponse> {
.then((res) => res.data);
}
export { apiClient, setTokens, clearTokens, getAccessToken, getRefreshToken };
export function getProfile(): Promise<UserProfile> {
return apiClient
.get<UserProfile>("/api/auth/me/")
.then((res) => res.data);
}
export function logoutUser(refreshToken: string): Promise<void> {
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");
}
+1 -168
View File
@@ -732,28 +732,6 @@
"@types/babel__core" "^7.20.5"
react-refresh "^0.17.0"
agent-base@6:
version "6.0.2"
resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-6.0.2.tgz#49fff58577cfee3f37176feab4c22e00f86d7f77"
integrity sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==
dependencies:
debug "4"
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==
axios@^1.7.0:
version "1.16.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-1.16.1.tgz#517e29291d19d6e8cf919ff264f4fe157261ba12"
integrity sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==
dependencies:
follow-redirects "^1.16.0"
form-data "^4.0.5"
https-proxy-agent "^5.0.1"
proxy-from-env "^2.1.0"
babel-plugin-macros@^3.1.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/babel-plugin-macros/-/babel-plugin-macros-3.1.0.tgz#9ef6dc74deb934b4db344dc973ee851d148c50c1"
@@ -779,14 +757,6 @@ browserslist@^4.24.0:
node-releases "^2.0.36"
update-browserslist-db "^1.2.3"
call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6"
integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==
dependencies:
es-errors "^1.3.0"
function-bind "^1.1.2"
callsites@^3.0.0:
version "3.1.0"
resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73"
@@ -802,13 +772,6 @@ clsx@^2.1.1:
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
combined-stream@^1.0.8:
version "1.0.8"
resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f"
integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==
dependencies:
delayed-stream "~1.0.0"
convert-source-map@^1.5.0:
version "1.9.0"
resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f"
@@ -840,18 +803,13 @@ csstype@^3.0.2, csstype@^3.2.2, csstype@^3.2.3:
resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.2.3.tgz#ec48c0f3e993e50648c86da559e2610995cf989a"
integrity sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==
debug@4, debug@^4.1.0, debug@^4.3.1:
debug@^4.1.0, debug@^4.3.1:
version "4.4.3"
resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.3.tgz#c6ae432d9bd9662582fce08709b038c58e9e3d6a"
integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==
dependencies:
ms "^2.1.3"
delayed-stream@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619"
integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==
dom-helpers@^5.0.1:
version "5.2.1"
resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902"
@@ -860,15 +818,6 @@ dom-helpers@^5.0.1:
"@babel/runtime" "^7.8.7"
csstype "^3.0.2"
dunder-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a"
integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==
dependencies:
call-bind-apply-helpers "^1.0.1"
es-errors "^1.3.0"
gopd "^1.2.0"
electron-to-chromium@^1.5.328:
version "1.5.361"
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.361.tgz#b993bc7b34ea83f348aa1787a608ecf12e39b909"
@@ -881,33 +830,11 @@ error-ex@^1.3.1:
dependencies:
is-arrayish "^0.2.1"
es-define-property@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa"
integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==
es-errors@^1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f"
integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==
es-object-atoms@^1.0.0, es-object-atoms@^1.1.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz#a2d0b373205724dfa525d23b0c3e1b1ca582c99b"
integrity sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==
dependencies:
es-errors "^1.3.0"
es-set-tostringtag@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d"
integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==
dependencies:
es-errors "^1.3.0"
get-intrinsic "^1.2.6"
has-tostringtag "^1.0.2"
hasown "^2.0.2"
esbuild@^0.25.0:
version "0.25.12"
resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.12.tgz#97a1d041f4ab00c2fce2f838d2b9969a2d2a97a5"
@@ -960,22 +887,6 @@ find-root@^1.1.0:
resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4"
integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==
follow-redirects@^1.16.0:
version "1.16.0"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.16.0.tgz#28474a159d3b9d11ef62050a14ed60e4df6d61bc"
integrity sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==
form-data@^4.0.5:
version "4.0.5"
resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.5.tgz#b49e48858045ff4cbf6b03e1805cebcad3679053"
integrity sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==
dependencies:
asynckit "^0.4.0"
combined-stream "^1.0.8"
es-set-tostringtag "^2.1.0"
hasown "^2.0.2"
mime-types "^2.1.12"
fsevents@~2.3.2, fsevents@~2.3.3:
version "2.3.3"
resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
@@ -991,54 +902,6 @@ gensync@^1.0.0-beta.2:
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"
integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==
get-intrinsic@^1.2.6:
version "1.3.0"
resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01"
integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==
dependencies:
call-bind-apply-helpers "^1.0.2"
es-define-property "^1.0.1"
es-errors "^1.3.0"
es-object-atoms "^1.1.1"
function-bind "^1.1.2"
get-proto "^1.0.1"
gopd "^1.2.0"
has-symbols "^1.1.0"
hasown "^2.0.2"
math-intrinsics "^1.1.0"
get-proto@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1"
integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==
dependencies:
dunder-proto "^1.0.1"
es-object-atoms "^1.0.0"
gopd@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1"
integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==
has-symbols@^1.0.3, has-symbols@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338"
integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==
has-tostringtag@^1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc"
integrity sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==
dependencies:
has-symbols "^1.0.3"
hasown@^2.0.2:
version "2.0.4"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.4.tgz#8c62d8cb90beb2aad5d0a5b67581ad9854c3f003"
integrity sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==
dependencies:
function-bind "^1.1.2"
hasown@^2.0.3:
version "2.0.3"
resolved "https://registry.yarnpkg.com/hasown/-/hasown-2.0.3.tgz#5e5c2b15b60370a4c7930c383dfb76bf17bc403c"
@@ -1053,14 +916,6 @@ hoist-non-react-statics@^3.3.1:
dependencies:
react-is "^16.7.0"
https-proxy-agent@^5.0.1:
version "5.0.1"
resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6"
integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==
dependencies:
agent-base "6"
debug "4"
import-fresh@^3.2.1:
version "3.3.1"
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.1.tgz#9cecb56503c0ada1f2741dbbd6546e4b13b57ccf"
@@ -1120,23 +975,6 @@ lru-cache@^5.1.1:
dependencies:
yallist "^3.0.2"
math-intrinsics@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9"
integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==
mime-db@1.52.0:
version "1.52.0"
resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70"
integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==
mime-types@^2.1.12:
version "2.1.35"
resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a"
integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==
dependencies:
mime-db "1.52.0"
ms@^2.1.3:
version "2.1.3"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2"
@@ -1212,11 +1050,6 @@ prop-types@^15.6.2, prop-types@^15.8.1:
object-assign "^4.1.1"
react-is "^16.13.1"
proxy-from-env@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba"
integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==
react-dom@^19.0.0:
version "19.2.6"
resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.6.tgz#44a81b0bcca22da814c00847d09d01c8615529b7"