# 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": "" } ``` **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": "" }`, 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 0x00–0x1F 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 `` 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)