Files
job-tracker/docs/auth-implementation.md

224 lines
6.9 KiB
Markdown

# User Authentication — Separate Login and Register Pages
**Issue:** crisleo-hermes/job-tracker#3
**Feature branch:** feature/user-auth
**Date:** 2026-05-26
---
## Overview
Implement separate Login and Register pages with a custom User model using email as the unique identifier. Backend exposes two endpoints (`POST /api/auth/register/` and `POST /api/auth/login/`) and frontend provides dedicated pages with form validation, error handling, and JWT token management.
---
## Backend Specification
### Models
**`accounts.User`** (custom, extends `AbstractUser`)
| Field | Type | Constraints |
|---|---|---|
| `email` | `EmailField` | `unique=True`, `max_length=254` — used as the `USERNAME_FIELD` for authentication |
| `username` | `CharField` | `max_length=150`, `blank=True`, `null=True` — optional display name, NOT used for auth |
| `password` | (inherited) | `CharField(max_length=128)` — stored as Django PBKDF2 hash |
| `first_name` | (inherited) | `CharField(max_length=150, blank=True)` |
| `last_name` | (inherited) | `CharField(max_length=150, blank=True)` |
| `is_active` | (inherited) | `BooleanField(default=True)` |
| `is_staff` | (inherited) | `BooleanField(default=False)` |
| `date_joined` | (inherited) | `DateTimeField(auto_now_add=True)` |
**Meta:** `db_table = "accounts_user"`, `verbose_name = "User"`
**Manager:** `objects = UserManager()` (inherited from `AbstractUser`)
### API Endpoints
#### `POST /api/auth/register/`
Creates a new user account.
**Request:**
```json
{
"email": "user@example.com",
"password": "securePass123",
"password_confirm": "securePass123",
"first_name": "John",
"last_name": "Doe"
}
```
**Validation rules:**
- `email`: must match `^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$`, must be unique (case-insensitive check), normalized to lowercase
- `password`: minimum 8 characters
- `password_confirm`: must match `password`
- `first_name`, `last_name`: optional, max 150 characters
**Success response (201 Created):**
```json
{
"id": 1,
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe"
}
```
**Error response (400 Bad Request):**
```json
{
"email": ["A user with this email already exists."],
"password": ["Password must be at least 8 characters."],
"password_confirm": ["Passwords do not match."]
}
```
#### `POST /api/auth/login/`
Authenticates existing user and returns JWT tokens.
**Request:**
```json
{
"email": "user@example.com",
"password": "securePass123"
}
```
**Validation rules:**
- Both `email` and `password` are required
- Invalid credentials return generic "Invalid email or password" (no user enumeration)
- Inactive accounts are rejected with "This account is inactive"
**Success response (200 OK):**
```json
{
"user": {
"id": 1,
"email": "user@example.com",
"first_name": "John",
"last_name": "Doe"
},
"access": "eyJ0eXAiOiJKV1Qi...",
"refresh": "eyJ0eXAiOiJKV1Qi..."
}
```
**Error response (401 Unauthorized):**
```json
{
"non_field_errors": ["Invalid email or password."]
}
```
### Django REST Framework Configuration
- **Auth classes:** `JWTAuthentication` (from `rest_framework_simplejwt`)
- **Default permissions:** `AllowAny` (auth endpoints are public by design)
- **Renderer:** `JSONRenderer` only (no browsable API in production)
- **SimpleJWT config:** Access token TTL = 24h, Refresh token TTL = 30d, Auth header type = `Bearer`
---
## Frontend Specification
### Component Tree
```
App
├── BrowserRouter
│ └── AuthProvider (context)
│ ├── Route "/" → HomePage
│ ├── Route "/login" → LoginPage
│ └── Route "/register" → RegisterPage
```
### AuthContext (`contexts/AuthContext.tsx`)
**State shape:**
```typescript
interface AuthState {
user: UserProfile | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
}
```
**Actions (via `useReducer`):**
| Action | Trigger | State change |
|---|---|---|
| `AUTH_START` | API call initiated | `isLoading=true`, `error=null` |
| `AUTH_SUCCESS` | Login succeeds | `isLoading=false`, `isAuthenticated=true`, `user=payload` |
| `AUTH_FAILURE` | API error | `isLoading=false`, `error=payload` |
| `LOGOUT` | User clicks sign out | Reset to `initialState`, clear localStorage tokens |
| `CLEAR_ERROR` | User dismisses / new attempt | `error=null` |
**Exposed methods:** `login(payload)`, `register(payload)`, `logout()`, `clearError()`
**Key behavior:**
- `login()`: stores `access_token` and `refresh_token` in `localStorage`, dispatches `AUTH_SUCCESS`, redirects to HomePage
- `register()`: calls registration API, dispatches `AUTH_START`/`AUTH_FAILURE` only — does NOT auto-authenticate; the RegisterPage handles redirect to /login
- `logout()`: clears localStorage tokens, dispatches `LOGOUT`
### Page Flows
#### Registration Flow
1. User fills form (first_name, last_name, email, password, password_confirm)
2. Client-side: email format, password min 8 chars, password match
3. `POST /api/auth/register/`
4. On success → redirect to `/login` with success message
5. On error → display server-side validation errors
#### Login Flow
1. User fills form (email, password)
2. `POST /api/auth/login/`
3. On success → store JWT tokens in localStorage, redirect to `/`
4. On error → display "Invalid email or password"
### API Service (`services/authApi.ts`)
Axios-based HTTP client with:
- **Base URL:** from `VITE_API_URL` env var (default: `http://localhost:8000`)
- **Auth interceptor:** auto-attaches `Bearer <access_token>` from localStorage on all requests
- **Functions:**
- `registerUser(payload: RegisterPayload): Promise<UserProfile>`
- `loginUser(payload: LoginPayload): Promise<LoginResponse>`
### Environment Variables
| Variable | Default | Description |
|---|---|---|
| `VITE_API_URL` | `http://localhost:8000` | Django API base URL |
---
## Validation Summary
| Check | Location | Implementation |
|---|---|---|
| Email format | Frontend + Backend | Regex on serializer (backend), `type="email"` on input (frontend) |
| Email uniqueness | Backend only | `User.objects.filter(email__iexact=...).exists()` in serializer |
| Password length | Backend + Frontend | `len(value) < 8` in serializer, `minLength={8}` on input (frontend) |
| Password match | Backend + Frontend | Serializer `validate()` method, `===` check in client form (implicit via payload) |
| Required fields | Backend + Frontend | `serializers.CharField(required=True)` + `required` attribute on inputs |
---
## Security Considerations
- Passwords hashed using Django's PBKDF2HMAC (adaptive, recommended by OWASP)
- No user enumeration on login (generic "Invalid email or password" message)
- Inactive accounts rejected at login time
- User emails normalized to lowercase before storage
- JWT tokens stored in localStorage (trade-off: simple SPA integration vs XSS exposure — refresh tokens mitigate short exposure)
- `AUTH_HEADER_TYPES` set to `("Bearer",)` following RFC 6750