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:
@@ -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 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 `<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)
|
||||
Reference in New Issue
Block a user