feat: implement API security measures phase 1
This commit is contained in:
+12
-2
@@ -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",)
|
||||
@@ -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")
|
||||
@@ -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
@@ -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})"
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user