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
+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()