feat: implement API security measures phase 1

This commit is contained in:
Marko (Hermes Implementer)
2026-05-27 00:24:09 +00:00
parent a0b3ae7e34
commit c2fc7bc15f
21 changed files with 1050 additions and 44 deletions
+12 -2
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)
@@ -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",)
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')],
},
),
]
+31 -1
View File
@@ -26,4 +26,34 @@ class User(AbstractUser):
verbose_name_plural = "Users"
def __str__(self) -> str:
return self.email
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
View File
@@ -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"),
]
+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):
@@ -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,
)
@@ -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
+26 -5
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(
@@ -125,4 +146,4 @@ USE_TZ = True
STATIC_URL = "/static/"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"