695 lines
17 KiB
Markdown

# Auth Module
## Overview
The Auth module provides JWT-based authentication with access and refresh tokens. It handles user registration, login, token refresh, and logout operations with comprehensive security features including password policy enforcement, rate limiting, and user enumeration prevention.
**Key Features:**
- JWT access tokens (15-minute expiry)
- Refresh token rotation with atomic transactions
- Password policy enforcement (12+ characters, complexity requirements)
- Rate limiting (10 requests/minute per IP)
- User enumeration prevention
- Account status validation (ACTIVE, SUSPENDED, BANNED)
- Temporary user expiration handling
- Prometheus metrics integration
## File Paths
| File | Purpose |
|------|---------|
| `api/src/modules/auth/auth.routes.ts` | Express router with 5 endpoints |
| `api/src/modules/auth/auth.service.ts` | Authentication business logic |
| `api/src/modules/auth/auth.schemas.ts` | Zod validation schemas |
## Database Models
### User Model
```prisma
model User {
id String @id @default(cuid())
email String @unique
password String
name String?
phone String?
role UserRole @default(USER)
status UserStatus @default(ACTIVE)
permissions Json?
createdVia String? @default("web")
emailVerified Boolean @default(false)
expiresAt DateTime? // For TEMP users
lastLoginAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
refreshTokens RefreshToken[]
}
enum UserRole {
SUPER_ADMIN
INFLUENCE_ADMIN
MAP_ADMIN
USER
TEMP
}
enum UserStatus {
ACTIVE
SUSPENDED
BANNED
}
```
### RefreshToken Model
```prisma
model RefreshToken {
id String @id @default(cuid())
token String @unique
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
expiresAt DateTime
createdAt DateTime @default(now())
}
```
## API Endpoints
| Method | Path | Auth | Rate Limit | Description |
|--------|------|------|------------|-------------|
| POST | `/api/auth/login` | None | 10/min | Authenticate user with email/password |
| POST | `/api/auth/register` | None | 10/min | Create new user account |
| POST | `/api/auth/refresh` | None | 10/min | Refresh access token |
| POST | `/api/auth/logout` | None | 10/min | Invalidate refresh token |
| GET | `/api/auth/me` | Required | None | Get current user profile |
## Endpoint Details
### POST /api/auth/login
Authenticate user with email and password.
**Request Body:**
```json
{
"email": "user@example.com",
"password": "SecurePass123"
}
```
**Response (200 OK):**
```json
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "John Doe",
"phone": null,
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"emailVerified": false,
"expiresAt": null,
"lastLoginAt": "2026-02-11T12:00:00.000Z",
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Error Responses:**
- `401 Unauthorized`: Invalid email or password (prevents user enumeration)
- `403 Forbidden`: Account is suspended/banned or expired
- `429 Too Many Requests`: Rate limit exceeded
**Implementation:**
```typescript
router.post(
'/login',
authRateLimit,
validate(loginSchema),
async (req: Request, res: Response, next: NextFunction) => {
try {
const result = await authService.login(req.body.email, req.body.password);
res.json(result);
} catch (err) {
next(err);
}
}
);
```
**Security Features:**
1. **User Enumeration Prevention**: Same error message for invalid email or password
2. **Account Status Validation**: Checks ACTIVE, SUSPENDED, BANNED, expired states
3. **Login Metrics**: Records success/failure for monitoring
4. **Last Login Tracking**: Updates `lastLoginAt` timestamp
5. **Password Comparison**: Uses bcrypt with 12 salt rounds
---
### POST /api/auth/register
Create a new user account. Public endpoint restricted to USER role.
**Request Body:**
```json
{
"email": "newuser@example.com",
"password": "SecurePass123",
"name": "Jane Smith",
"phone": "+1234567890"
}
```
**Response (201 Created):**
```json
{
"user": {
"id": "clx0987654321",
"email": "newuser@example.com",
"name": "Jane Smith",
"phone": "+1234567890",
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"emailVerified": false,
"expiresAt": null,
"lastLoginAt": null,
"createdAt": "2026-02-11T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Error Responses:**
- `409 Conflict`: Email already registered
- `400 Bad Request`: Password doesn't meet complexity requirements
- `429 Too Many Requests`: Rate limit exceeded
**Password Policy:**
```typescript
password: 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')
```
**Security Notes:**
- Role is **always** set to `USER` server-side (not user-controllable)
- Password hashed with bcrypt (12 salt rounds)
- Immediately issues access + refresh tokens (auto-login after registration)
---
### POST /api/auth/refresh
Refresh access token using a valid refresh token. Implements token rotation for security.
**Request Body:**
```json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Response (200 OK):**
```json
{
"user": {
"id": "clx1234567890",
"email": "user@example.com",
"name": "John Doe",
"role": "USER",
"status": "ACTIVE",
...
},
"accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Error Responses:**
- `401 Unauthorized`: Invalid, expired, or not found refresh token
**Token Rotation Flow:**
```typescript
// Atomic transaction ensures no race condition
const tokens = await prisma.$transaction(async (tx) => {
// 1. Delete old refresh token
await tx.refreshToken.delete({ where: { id: stored.id } });
// 2. Generate new token pair
const accessToken = this.generateAccessToken(stored.user);
const refreshToken = jwt.sign(refreshPayload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY,
});
// 3. Store new refresh token
await tx.refreshToken.create({
data: {
token: refreshToken,
userId: stored.user.id,
expiresAt: new Date(decoded.exp * 1000),
},
});
return { accessToken, refreshToken };
});
```
**Security Features:**
1. **Atomic Rotation**: Old token deleted and new token created in single transaction
2. **Expiration Check**: Validates refresh token hasn't expired
3. **Database Validation**: Checks token exists in database (prevents replay attacks)
4. **Automatic Cleanup**: Expired tokens deleted on access attempt
---
### POST /api/auth/logout
Invalidate a refresh token.
**Request Body:**
```json
{
"refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
}
```
**Response (200 OK):**
```json
{
"message": "Logged out"
}
```
**Implementation:**
```typescript
async logout(refreshToken: string) {
await prisma.refreshToken.deleteMany({ where: { token: refreshToken } });
}
```
**Notes:**
- Uses `deleteMany` (safe if token doesn't exist)
- Client should discard access token immediately
- Access tokens remain valid until expiry (15 minutes)
---
### GET /api/auth/me
Get current authenticated user's profile.
**Request Headers:**
```
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
```
**Response (200 OK):**
```json
{
"id": "clx1234567890",
"email": "user@example.com",
"name": "John Doe",
"phone": null,
"role": "USER",
"status": "ACTIVE",
"permissions": null,
"createdVia": "web",
"emailVerified": false,
"lastLoginAt": "2026-02-11T12:00:00.000Z",
"createdAt": "2026-02-01T12:00:00.000Z",
"updatedAt": "2026-02-11T12:00:00.000Z"
}
```
**Error Responses:**
- `401 Unauthorized`: Missing, invalid, or expired access token
- `401 Unauthorized`: User not found (prevents user enumeration - same code as invalid token)
**Security Note:**
Returns `401` instead of `404` when user not found to prevent user enumeration.
## Service Functions
### authService.login(email, password)
**Purpose:** Authenticate user and generate token pair.
**Flow:**
1. Find user by email
2. Compare password with bcrypt
3. Validate account status (ACTIVE, not expired)
4. Record login metrics
5. Update `lastLoginAt` timestamp
6. Generate access + refresh token pair
7. Return user (without password) + tokens
**Error Handling:**
```typescript
if (!user) {
recordLoginAttempt('failure');
throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
}
const valid = await bcrypt.compare(password, user.password);
if (!valid) {
recordLoginAttempt('failure');
throw new AppError(401, 'Invalid email or password', 'INVALID_CREDENTIALS');
}
if (user.status !== UserStatus.ACTIVE) {
recordLoginAttempt('failure');
throw new AppError(403, `Account is ${user.status.toLowerCase()}`, 'ACCOUNT_INACTIVE');
}
```
---
### authService.register(data)
**Purpose:** Create new user account with hashed password.
**Flow:**
1. Check if email already exists
2. Hash password with bcrypt (12 salt rounds)
3. Create user with `USER` role
4. Generate token pair
5. Return user (without password) + tokens
**Implementation:**
```typescript
const hashedPassword = await bcrypt.hash(data.password, 12);
const user = await prisma.user.create({
data: {
email: data.email,
password: hashedPassword,
name: data.name,
phone: data.phone,
role: UserRole.USER, // Always USER for public registration
},
});
```
---
### authService.refreshTokens(refreshToken)
**Purpose:** Rotate refresh token and issue new access token.
**Security:**
- Atomic transaction (delete old + create new)
- Validates token signature with `JWT_REFRESH_SECRET`
- Checks database for token existence
- Validates expiration timestamp
- Prevents replay attacks
---
### authService.generateAccessToken(user)
**Purpose:** Create short-lived JWT for API authentication.
**Token Payload:**
```typescript
interface TokenPayload {
id: string;
email: string;
role: UserRole;
}
```
**Configuration:**
- Secret: `JWT_ACCESS_SECRET` environment variable
- Expiry: `JWT_ACCESS_EXPIRY` (default: `15m`)
- Algorithm: HS256 (HMAC with SHA-256)
**Usage:**
```typescript
const accessToken = authService.generateAccessToken(user);
// Returns: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
---
### authService.generateRefreshToken(user)
**Purpose:** Create long-lived JWT and store in database.
**Configuration:**
- Secret: `JWT_REFRESH_SECRET` (must differ from access secret)
- Expiry: `JWT_REFRESH_EXPIRY` (default: `7d`)
- Storage: Database (RefreshToken table)
**Implementation:**
```typescript
const token = jwt.sign(payload, env.JWT_REFRESH_SECRET, {
expiresIn: env.JWT_REFRESH_EXPIRY as SignOptions['expiresIn'],
});
const decoded = jwt.decode(token) as { exp: number };
const expiresAt = new Date(decoded.exp * 1000);
await prisma.refreshToken.create({
data: {
token,
userId: user.id,
expiresAt,
},
});
```
## Code Examples
### Complete Login Flow
```typescript
// Client: Login request
const response = await axios.post('/api/auth/login', {
email: 'user@example.com',
password: 'SecurePass123'
});
const { user, accessToken, refreshToken } = response.data;
// Store tokens
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
// Use access token for API requests
axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
```
### Token Refresh Flow
```typescript
// Client: 401 interceptor for automatic token refresh
axios.interceptors.response.use(
response => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
// Redirect to login
window.location.href = '/login';
return Promise.reject(error);
}
try {
const { data } = await axios.post('/api/auth/refresh', { refreshToken });
// Update stored tokens
localStorage.setItem('accessToken', data.accessToken);
localStorage.setItem('refreshToken', data.refreshToken);
// Retry original request with new token
error.config.headers['Authorization'] = `Bearer ${data.accessToken}`;
return axios(error.config);
} catch (refreshError) {
// Refresh failed, redirect to login
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
window.location.href = '/login';
return Promise.reject(refreshError);
}
}
return Promise.reject(error);
}
);
```
### Protected Route Middleware
```typescript
// Server: Protect routes with authentication
import { authenticate } from '../../middleware/auth.middleware';
router.get('/protected', authenticate, async (req, res) => {
// req.user is populated by authenticate middleware
const userId = req.user!.id;
const userRole = req.user!.role;
res.json({ message: 'Authenticated!', userId, userRole });
});
```
### Role-Based Access Control
```typescript
import { requireRole } from '../../middleware/rbac.middleware';
import { UserRole } from '@prisma/client';
// Only SUPER_ADMIN can access
router.delete('/users/:id',
authenticate,
requireRole(UserRole.SUPER_ADMIN),
async (req, res) => {
// Delete user logic
}
);
// Multiple roles allowed
router.post('/campaigns',
authenticate,
requireRole(UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN),
async (req, res) => {
// Create campaign logic
}
);
```
## Environment Configuration
Required environment variables:
```env
# JWT Access Token (15 minutes)
JWT_ACCESS_SECRET=<random-32-byte-hex>
JWT_ACCESS_EXPIRY=15m
# JWT Refresh Token (7 days)
JWT_REFRESH_SECRET=<different-random-32-byte-hex>
JWT_REFRESH_EXPIRY=7d
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/changemaker_v2
# Redis (for rate limiting)
REDIS_URL=redis://:password@localhost:6379
REDIS_PASSWORD=<redis-password>
```
**Generate secrets:**
```bash
# Generate random secrets (macOS/Linux)
openssl rand -hex 32 # For JWT_ACCESS_SECRET
openssl rand -hex 32 # For JWT_REFRESH_SECRET (must differ!)
```
## Security Considerations
### Password Policy
- **Minimum length**: 12 characters
- **Complexity**: Uppercase, lowercase, digit required
- **Hashing**: bcrypt with 12 salt rounds
- **Enforcement**: Schema-level validation (cannot be bypassed)
### Rate Limiting
```typescript
// 10 requests per minute per IP
export const authRateLimit = rateLimit({
windowMs: 60 * 1000,
max: 10,
message: 'Too many requests, please try again later',
standardHeaders: true,
legacyHeaders: false,
keyGenerator: (req) => req.ip,
store: new RedisStore({
client: redis,
prefix: 'rl:auth:',
}),
});
```
### User Enumeration Prevention
- Login/register errors don't reveal if email exists
- `/api/auth/me` returns `401` (not `404`) when user not found
- Consistent error messages and response times
### Token Security
- **Access tokens**: Short-lived (15 min), stored in memory
- **Refresh tokens**: Long-lived (7 days), stored in database + httpOnly cookie
- **Rotation**: Refresh tokens rotated on each use (atomic transaction)
- **Secrets**: Access and refresh use different secrets (prevents cross-contamination)
- **Expiration**: Automatic cleanup of expired tokens
### Database Security
- Passwords **never** returned in API responses (excluded via `select` or destructuring)
- Refresh tokens cascade deleted when user deleted
- Unique constraint on `email` prevents duplicates
- Foreign key constraints ensure referential integrity
## Related Documentation
- [Architecture: Authentication](/v2/architecture/authentication.md) - Auth flow diagrams
- [Middleware: Auth](/v2/backend/middleware/auth.md) - JWT verification middleware
- [Middleware: RBAC](/v2/backend/middleware/rbac.md) - Role-based access control
- [Middleware: Rate Limit](/v2/backend/middleware/rate-limit.md) - Rate limiting configuration
- [Frontend: Auth Store](/v2/frontend/components/auth-store.md) - Zustand auth state management
- [API Reference: Auth](/v2/api-reference/auth.md) - Complete endpoint reference
- [User Guide: Admin](/v2/user-guides/admin-guide.md) - Managing user accounts
- [Security Audit](/SECURITY_AUDIT_2025-02-11.md) - Feb 2026 security review