801 lines
21 KiB
Markdown

# Authentication Flow
Changemaker Lite V2 uses JWT-based authentication with access and refresh tokens for stateless, scalable authentication.
## Overview
**Key Features:**
- **JWT Tokens** - Stateless authentication (no session storage)
- **Dual Token System** - Short-lived access tokens (15min) + long-lived refresh tokens (7 days)
- **Refresh Token Rotation** - Atomic transaction prevents race conditions
- **Password Policy** - Enforced 12+ characters with complexity requirements
- **Rate Limiting** - 10 requests/min on auth endpoints
- **User Enumeration Prevention** - Consistent 401 responses
- **RBAC** - Role-based access control with 5 roles
## Authentication Architecture
```mermaid
graph TB
subgraph "Client Layer"
Browser[Web Browser]
Storage[LocalStorage<br/>Zustand Persist]
end
subgraph "API Layer"
AuthRoutes[Auth Routes<br/>/api/auth/*]
AuthMiddleware[Auth Middleware<br/>JWT Verification]
RBACMiddleware[RBAC Middleware<br/>Role Check]
end
subgraph "Data Layer"
PG[(PostgreSQL<br/>User + RefreshToken)]
Redis[(Redis<br/>Rate Limiting)]
end
Browser -->|POST /auth/login| AuthRoutes
AuthRoutes -->|Check rate limit| Redis
AuthRoutes -->|Verify credentials| PG
AuthRoutes -->|Generate tokens| AuthRoutes
AuthRoutes -->|Store refresh token| PG
AuthRoutes -->|Return tokens| Browser
Browser -->|Store| Storage
Browser -->|API requests| AuthMiddleware
AuthMiddleware -->|Verify JWT| AuthMiddleware
AuthMiddleware -->|Check role| RBACMiddleware
RBACMiddleware -->|Authorized| Handler[Route Handler]
style AuthRoutes fill:#61dafb,stroke:#333,stroke-width:2px
style AuthMiddleware fill:#ffd700,stroke:#333,stroke-width:2px
style RBACMiddleware fill:#ff6b6b,stroke:#333,stroke-width:2px
```
## User Roles
### Role Hierarchy
```typescript
enum UserRole {
SUPER_ADMIN = 'SUPER_ADMIN', // Full system access
INFLUENCE_ADMIN = 'INFLUENCE_ADMIN', // Campaign management
MAP_ADMIN = 'MAP_ADMIN', // Location + canvassing management
USER = 'USER', // Standard user (limited access)
TEMP = 'TEMP' // Temporary user (public signups, auto-expires)
}
```
### Role Permissions
| Role | Campaign CRUD | Response Moderation | Location Management | User Management | System Settings |
|------|---------------|---------------------|---------------------|-----------------|-----------------|
| **SUPER_ADMIN** | ✅ | ✅ | ✅ | ✅ | ✅ |
| **INFLUENCE_ADMIN** | ✅ | ✅ | ❌ | ❌ | ❌ |
| **MAP_ADMIN** | ❌ | ❌ | ✅ | ❌ | ❌ |
| **USER** | ❌ | ❌ | ❌ | ❌ | ❌ |
| **TEMP** | ❌ | ❌ | ❌ | ❌ | ❌ |
**TEMP User Behavior:**
- Created automatically for public shift signups
- Auto-expires after configured days (`expiresAt`, `expireDays` fields)
- Limited to volunteer canvassing features
- Cannot access admin pages
## Login Flow
### Sequence Diagram
```mermaid
sequenceDiagram
participant User
participant React as Admin GUI
participant Nginx
participant API as Express API
participant Redis
participant PG as PostgreSQL
User->>React: Enter email + password
React->>Nginx: POST /api/auth/login
Nginx->>API: Forward request
API->>Redis: Rate limit check (10/min)
alt Rate limit exceeded
Redis-->>API: Too many requests
API-->>React: 429 Too Many Requests
React-->>User: "Try again later"
else Rate limit OK
API->>PG: SELECT * FROM User WHERE email = ?
alt User not found
PG-->>API: null
API-->>React: 401 Unauthorized
React-->>User: "Invalid credentials"
else User found
PG-->>API: User record
API->>API: bcrypt.compare(password, hash)
alt Password invalid
API-->>React: 401 Unauthorized
React-->>User: "Invalid credentials"
else Password valid
API->>API: Check user status
alt Status SUSPENDED
API-->>React: 403 Forbidden
React-->>User: "Account suspended"
else Status ACTIVE
API->>API: jwt.sign(accessPayload, 15min)
API->>API: jwt.sign(refreshPayload, 7d)
API->>PG: INSERT RefreshToken
API->>PG: UPDATE lastLoginAt
API-->>React: { user, accessToken, refreshToken }
React->>React: Store in Zustand + localStorage
React-->>User: Redirect to dashboard
end
end
end
end
```
### Implementation
**File:** `api/src/modules/auth/auth.service.ts` (lines 22-56)
```typescript
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { prisma } from '../../config/database';
import { loginSchema } from './auth.schemas';
import { incrementMetric } from '../../utils/metrics';
export async function login(credentials: { email: string; password: string }) {
// Validate input
const { email, password } = loginSchema.parse(credentials);
// Find user
const user = await prisma.user.findUnique({
where: { email },
select: {
id: true,
email: true,
password: true,
name: true,
role: true,
status: true,
emailVerified: true,
expiresAt: true
}
});
// User enumeration prevention: consistent 401 response
if (!user) {
throw new Error('Invalid credentials'); // Returns 401
}
// Verify password
const isValid = await bcrypt.compare(password, user.password);
if (!isValid) {
throw new Error('Invalid credentials'); // Returns 401
}
// Check user status
if (user.status === 'SUSPENDED') {
throw new Error('Account suspended'); // Returns 403
}
if (user.status === 'INACTIVE') {
throw new Error('Account inactive'); // Returns 403
}
// Check TEMP user expiration
if (user.expiresAt && new Date() > user.expiresAt) {
await prisma.user.update({
where: { id: user.id },
data: { status: 'EXPIRED' }
});
throw new Error('Account expired'); // Returns 403
}
// Generate access token (15 minutes)
const accessToken = jwt.sign(
{ id: user.id, email: user.email, role: user.role },
process.env.JWT_ACCESS_SECRET!,
{ expiresIn: '15m' as const }
);
// Generate refresh token (7 days)
const refreshToken = jwt.sign(
{ id: user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' as const }
);
// Store refresh token in database
await prisma.refreshToken.create({
data: {
token: refreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) // 7 days
}
});
// Update last login timestamp
await prisma.user.update({
where: { id: user.id },
data: { lastLoginAt: new Date() }
});
// Increment metrics
incrementMetric('cm_login_attempts_total', { status: 'success', role: user.role });
// Return user (no password) + tokens
const { password: _, ...userWithoutPassword } = user;
return {
user: userWithoutPassword,
accessToken,
refreshToken
};
}
```
### Password Policy
**Enforced at Zod schema level:**
**File:** `api/src/modules/auth/auth.schemas.ts` (lines 9-16)
```typescript
import { z } from 'zod';
export const passwordSchema = z
.string()
.min(12, 'Password must be at least 12 characters')
.regex(/[A-Z]/, 'Password must contain at least one uppercase letter')
.regex(/[a-z]/, 'Password must contain at least one lowercase letter')
.regex(/[0-9]/, 'Password must contain at least one digit');
export const registerSchema = z.object({
email: z.string().email('Invalid email address'),
password: passwordSchema,
name: z.string().min(2, 'Name must be at least 2 characters')
});
export const loginSchema = z.object({
email: z.string().email('Invalid email address'),
password: z.string().min(1, 'Password is required')
});
```
**Policy Requirements:**
- Minimum 12 characters
- At least one uppercase letter (A-Z)
- At least one lowercase letter (a-z)
- At least one digit (0-9)
**Note:** Policy is NOT enforced on login (only on registration/password change) to avoid breaking existing accounts.
## Refresh Token Flow
### Sequence Diagram
```mermaid
sequenceDiagram
participant React as Admin GUI
participant API as Express API
participant PG as PostgreSQL
Note over React: Access token expires (15min)
React->>React: Detect 401 Unauthorized
React->>API: POST /api/auth/refresh
Note right of React: Send refresh token
API->>API: jwt.verify(refreshToken)
alt Token invalid/expired
API-->>React: 401 Unauthorized
React->>React: Clear auth state
React-->>User: Redirect to login
else Token valid
API->>PG: BEGIN TRANSACTION
API->>PG: SELECT RefreshToken WHERE token = ?
alt Token not in database
API->>PG: ROLLBACK
API-->>React: 401 Unauthorized
else Token found
API->>PG: DELETE FROM RefreshToken WHERE token = ?
API->>API: Generate new access token (15min)
API->>API: Generate new refresh token (7d)
API->>PG: INSERT new RefreshToken
API->>PG: COMMIT TRANSACTION
API-->>React: { accessToken, refreshToken }
React->>React: Update stored tokens
React->>React: Retry original request
end
end
```
### Implementation
**File:** `api/src/modules/auth/auth.service.ts` (lines 82-130)
```typescript
export async function refreshTokens(refreshToken: string) {
// Verify refresh token signature
let payload: any;
try {
payload = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET!);
} catch (err) {
throw new Error('Invalid refresh token'); // Returns 401
}
// Atomic transaction for token rotation
const result = await prisma.$transaction(async (tx) => {
// Check if refresh token exists in database
const storedToken = await tx.refreshToken.findUnique({
where: { token: refreshToken },
include: { user: true }
});
if (!storedToken) {
throw new Error('Refresh token not found'); // Returns 401
}
// Check expiration
if (new Date() > storedToken.expiresAt) {
// Delete expired token
await tx.refreshToken.delete({
where: { token: refreshToken }
});
throw new Error('Refresh token expired'); // Returns 401
}
// Check user status
if (storedToken.user.status !== 'ACTIVE') {
throw new Error('User account not active'); // Returns 403
}
// Delete old refresh token (rotation)
await tx.refreshToken.delete({
where: { token: refreshToken }
});
// Generate new access token
const newAccessToken = jwt.sign(
{ id: storedToken.user.id, email: storedToken.user.email, role: storedToken.user.role },
process.env.JWT_ACCESS_SECRET!,
{ expiresIn: '15m' as const }
);
// Generate new refresh token
const newRefreshToken = jwt.sign(
{ id: storedToken.user.id, type: 'refresh' },
process.env.JWT_REFRESH_SECRET!,
{ expiresIn: '7d' as const }
);
// Store new refresh token
await tx.refreshToken.create({
data: {
token: newRefreshToken,
userId: storedToken.user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000)
}
});
return {
accessToken: newAccessToken,
refreshToken: newRefreshToken
};
});
return result;
}
```
**Critical:** Refresh token rotation happens in a **single database transaction** to prevent race conditions (e.g., multiple refresh attempts).
## Frontend Integration
### Zustand Auth Store
**File:** `admin/src/stores/auth.store.ts` (lines 1-100)
```typescript
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface User {
id: string;
email: string;
name: string | null;
role: string;
}
interface AuthState {
user: User | null;
accessToken: string | null;
refreshToken: string | null;
isAuthenticated: boolean;
login: (user: User, accessToken: string, refreshToken: string) => void;
logout: () => void;
updateTokens: (accessToken: string, refreshToken: string) => void;
}
export const useAuthStore = create<AuthState>()(
persist(
(set) => ({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false,
login: (user, accessToken, refreshToken) => {
set({
user,
accessToken,
refreshToken,
isAuthenticated: true
});
},
logout: () => {
set({
user: null,
accessToken: null,
refreshToken: null,
isAuthenticated: false
});
},
updateTokens: (accessToken, refreshToken) => {
set({ accessToken, refreshToken });
}
}),
{
name: 'auth-storage', // LocalStorage key
partialize: (state) => ({
user: state.user,
accessToken: state.accessToken,
refreshToken: state.refreshToken,
isAuthenticated: state.isAuthenticated
})
}
)
);
```
### Axios 401 Interceptor
**File:** `admin/src/lib/api.ts` (lines 34-78)
```typescript
import axios from 'axios';
import { useAuthStore } from '../stores/auth.store';
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json'
}
});
// Request interceptor: Add access token to all requests
api.interceptors.request.use((config) => {
const { accessToken } = useAuthStore.getState();
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
});
// Response interceptor: Handle 401 with token refresh
let isRefreshing = false;
let refreshCallbacks: ((token: string) => void)[] = [];
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// If 401 and we haven't tried refreshing yet
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
const { refreshToken, updateTokens, logout } = useAuthStore.getState();
if (!refreshToken) {
logout();
window.location.href = '/login';
return Promise.reject(error);
}
// Deduplicate refresh requests (only one refresh at a time)
if (isRefreshing) {
// Wait for ongoing refresh to complete
return new Promise((resolve) => {
refreshCallbacks.push((token: string) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
resolve(api(originalRequest));
});
});
}
isRefreshing = true;
try {
// Refresh tokens
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
// Update stored tokens
updateTokens(data.accessToken, data.refreshToken);
// Retry original request with new token
originalRequest.headers.Authorization = `Bearer ${data.accessToken}`;
// Resolve queued requests
refreshCallbacks.forEach((callback) => callback(data.accessToken));
refreshCallbacks = [];
return api(originalRequest);
} catch (refreshError) {
// Refresh failed, logout
logout();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
return Promise.reject(error);
}
);
```
**Key Features:**
- Automatic token refresh on 401
- Deduplicates concurrent refresh requests (callback queue)
- Retries original request after refresh
- Logs out on refresh failure
## Middleware
### JWT Verification
**File:** `api/src/middleware/auth.ts` (lines 1-35)
```typescript
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
export interface AuthUser {
id: string;
email: string;
role: string;
}
declare global {
namespace Express {
interface Request {
user?: AuthUser;
}
}
}
export const authenticate = (req: Request, res: Response, next: NextFunction) => {
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith('Bearer ')) {
return res.status(401).json({ error: 'No token provided' });
}
const token = authHeader.split(' ')[1];
try {
const payload = jwt.verify(token, process.env.JWT_ACCESS_SECRET!) as AuthUser;
req.user = payload; // Attach user to request
next();
} catch (err) {
return res.status(401).json({ error: 'Invalid or expired token' });
}
};
```
### Role-Based Access Control (RBAC)
**File:** `api/src/middleware/auth.ts` (lines 37-55)
```typescript
export const requireRole = (...allowedRoles: string[]) => {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.user) {
return res.status(401).json({ error: 'Not authenticated' });
}
if (!allowedRoles.includes(req.user.role)) {
return res.status(403).json({
error: 'Insufficient permissions',
required: allowedRoles,
current: req.user.role
});
}
next();
};
};
// Block TEMP users from specific routes
export const requireNonTemp = (req: Request, res: Response, next: NextFunction) => {
if (req.user?.role === 'TEMP') {
return res.status(403).json({ error: 'Temporary users cannot access this resource' });
}
next();
};
```
**Usage:**
```typescript
import { authenticate, requireRole, requireNonTemp } from './middleware/auth';
// Require authentication
router.get('/profile', authenticate, getProfile);
// Require specific role
router.post('/campaigns', authenticate, requireRole('SUPER_ADMIN', 'INFLUENCE_ADMIN'), createCampaign);
// Block TEMP users
router.post('/users', authenticate, requireNonTemp, createUser);
```
## Rate Limiting
**File:** `api/src/middleware/rate-limit.ts` (lines 1-45)
```typescript
import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';
import { redis } from '../config/redis';
// Auth endpoints: 10 requests per minute
export const authRateLimit = rateLimit({
store: new RedisStore({
client: redis,
prefix: 'rl:auth:',
sendCommand: (...args: string[]) => redis.call(...args)
}),
windowMs: 60 * 1000, // 1 minute
max: 10,
message: 'Too many auth requests, please try again later',
standardHeaders: true,
legacyHeaders: false
});
// Apply to auth routes
import authRoutes from './modules/auth/auth.routes';
app.use('/api/auth/login', authRateLimit);
app.use('/api/auth/register', authRateLimit);
app.use('/api/auth/refresh', authRateLimit);
```
## Security Features
### 1. User Enumeration Prevention
**Problem:** Attackers can enumerate valid emails by observing different error messages.
**Solution:** Consistent 401 response for both "user not found" and "invalid password":
```typescript
if (!user) {
throw new Error('Invalid credentials'); // Same message
}
if (!isValidPassword) {
throw new Error('Invalid credentials'); // Same message
}
```
### 2. Password Hashing
**bcryptjs with automatic salt generation:**
```typescript
import bcrypt from 'bcryptjs';
// Registration
const hashedPassword = await bcrypt.hash(password, 10); // 10 rounds
await prisma.user.create({
data: { email, password: hashedPassword, name, role: 'USER' }
});
// Login
const isValid = await bcrypt.compare(password, user.password);
```
**Rounds:** 10 (balanced between security and performance)
### 3. Refresh Token Rotation
**Prevents replay attacks:**
- Old refresh token deleted immediately after use (atomic transaction)
- New refresh token issued with each refresh
- If old token reused → 401 error
### 4. Token Expiration
| Token Type | Lifetime | Storage | Purpose |
|------------|----------|---------|---------|
| **Access** | 15 minutes | Not stored (JWT only) | API authentication |
| **Refresh** | 7 days | Database + localStorage | Token renewal |
**Short access token lifetime** limits damage if token is stolen.
### 5. Redis Authentication
Redis requires password authentication:
```bash
# .env
REDIS_PASSWORD=strong_password_here
# Redis connection
REDIS_URL=redis://:${REDIS_PASSWORD}@redis-changemaker:6379
```
## Troubleshooting
### Login Fails with Correct Password
**Cause:** User status not ACTIVE, or TEMP user expired.
**Solution:**
```sql
-- Check user status
SELECT email, status, expiresAt FROM "User" WHERE email = 'user@example.com';
-- Activate user
UPDATE "User" SET status = 'ACTIVE' WHERE email = 'user@example.com';
```
### Token Refresh Fails
**Cause:** Refresh token not in database (deleted or expired).
**Solution:**
```sql
-- Check if refresh token exists
SELECT * FROM "RefreshToken" WHERE token = 'token_here';
-- Delete all expired tokens
DELETE FROM "RefreshToken" WHERE "expiresAt" < NOW();
```
### 401 on All Requests
**Cause:** Access token missing, invalid, or expired.
**Debug:**
```bash
# Decode JWT (without verifying signature)
echo "eyJhbG..." | cut -d'.' -f2 | base64 -d | jq
# Check expiration
# Look for "exp" field (Unix timestamp)
```
### Circular Dependency (auth.store ↔ api.ts)
**Problem:** auth.store imports api.ts, api.ts imports auth.store (circular).
**Solution:** Callback registration pattern (already implemented in api.ts lines 34-78).
## Further Reading
- [RBAC Patterns](../backend/middleware/rbac.md) - Advanced role checks
- [Security Model](security.md) - Comprehensive security audit
- [Database Schema](database.md) - User and RefreshToken models
- [Frontend State Management](frontend.md) - Zustand auth store
- [API Reference: Auth](../api-reference/auth.md) - Complete endpoint docs