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

9.4 KiB

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:

{
  "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):

{
  "id": 1,
  "email": "user@example.com",
  "first_name": "John",
  "last_name": "Doe"
}

Error response (400 Bad Request):

{
  "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:

{
  "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):

{
  "user": {
    "id": 1,
    "email": "user@example.com",
    "first_name": "John",
    "last_name": "Doe"
  },
  "access": "eyJ0eXAiOiJKV1Qi...",
  "refresh": "eyJ0eXAiOiJKV1Qi..."
}

Error response (401 Unauthorized):

{
  "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:

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

Post-Merge Fixes (PR #14)

Issue: Issue #3 was re-opened after initial merge. Root cause: frontend error handling was using err.message from AxiosError objects, which produces "Request failed with status code 400" instead of the actual backend validation errors.

Changes

Area Change Detail
api/accounts/urls.py Added token/refresh/ endpoint Maps to rest_framework_simplejwt.views.TokenRefreshView
web/src/services/authApi.ts Error extraction New extractErrorMessage() function parses err.response.data — handles non_field_errors, detail, and field-level error arrays
web/src/services/authApi.ts Token refresh interceptor Axios response interceptor intercepts 401, refreshes access token using stored refresh_token, replays queued requests
web/src/services/authApi.ts Export setTokens, clearTokens, getAccessToken Shared token utility functions for AuthContext
web/src/contexts/AuthContext.tsx Fixed error handling Uses extractErrorMessage(err) instead of err instanceof Error ? err.message : ...
web/src/contexts/AuthContext.tsx Auth state restoration RESTORE_COMPLETE action; isRestoring flag prevents protected route flash on page refresh
web/src/contexts/AuthContext.tsx Persist user data Stores user_data in localStorage alongside tokens; restores UserProfile on page reload
web/src/components/ProtectedRoute.tsx New component Route guard — redirects to /login if unauthenticated; shows loading spinner during restoration
web/src/App.tsx Route restructure Public routes (/login, /register) outside ProtectedRoute; protected routes (/) inside it

Error handling flow

  1. Backend returns {"email": ["A user with this email already exists."]} (DRF standard)
  2. Axios interceptor catches non-401 responses, passes error through
  3. extractErrorMessage() iterates response.data keys, returns first error string
  4. AuthContext dispatches AUTH_FAILURE with extracted message
  5. LoginPage/RegisterPage renders state.error — user sees: "A user with this email already exists."

Token refresh flow

  1. User logs in — access_token and refresh_token stored in localStorage
  2. When access token expires, next API call receives 401
  3. Response interceptor catches it, calls POST /api/auth/token/refresh/
  4. On success — new access token stored, original request retried, queued requests replayed
  5. On failure — tokens cleared, all pending requests rejected with "Session expired"