801 lines
21 KiB
Markdown
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
|