Merge branch 'main' into fix/user-auth-error-handling

Resolved conflicts in:
  - api/accounts/urls.py: Took main's complete URL set (verify/refresh/logout/me)
  - web/src/App.tsx: Took main's route structure with PublicRoute/ProtectedRoute wrappers
  - web/src/services/authApi.ts: Combined main's robust interceptor with PR's extractErrorMessage + getAccessToken/getRefreshToken helpers
  - web/src/contexts/AuthContext.tsx: Combined main's full auth flow (refreshAccessToken, getProfile, logoutUser) with PR's extractErrorMessage and user_data persistence
This commit is contained in:
Marko (Hermes Implementer)
2026-05-29 02:24:51 +00:00
21 changed files with 1016 additions and 116 deletions
+186
View File
@@ -0,0 +1,186 @@
# 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)