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

187 lines
7.1 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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": "<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:
```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 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 `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)