Files
job-tracker/docs/backend/api-security-phase-1.md

7.1 KiB
Raw Permalink Blame History

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:

{ "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:

{ "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 JobApplication0002_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)