695 lines
17 KiB
Markdown
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
|