544 lines
18 KiB
Markdown

# Auth & Users Models
## Overview
The Auth & Users module provides JWT-based authentication with role-based access control (RBAC), temporary user support for public shift signups, and refresh token rotation for enhanced security.
**Models:**
- **User** — User accounts with roles and permissions
- **RefreshToken** — JWT refresh token storage with expiration
**Key Features:**
- bcrypt password hashing (12+ character policy enforced at schema level)
- JWT access tokens (15min) + refresh tokens (7 days)
- Refresh token rotation with atomic transactions
- Role hierarchy: SUPER_ADMIN > INFLUENCE_ADMIN > MAP_ADMIN > USER > TEMP
- Temporary user support with auto-expiration
- Email verification workflow
- User enumeration prevention (401 not 404)
---
## Models Summary
| Model | Table | Description |
|-------|-------|-------------|
| User | `users` | User accounts with RBAC, permissions, temp user support |
| RefreshToken | `refresh_tokens` | JWT refresh tokens with expiration tracking |
---
## User Model
### Purpose
The User model represents all system users, from super admins to temporary volunteers created via public shift signup. It supports role-based access control, granular permissions, temporary user expiration, and comprehensive audit tracking via 33 relation fields.
### Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| **Identity** | | | | |
| id | String | ✓ | `cuid()` | Primary key |
| email | String | ✓ | — | Unique email address (lowercase) |
| password | String | ✓ | — | bcrypt hashed (12+ chars, 1 uppercase, 1 lowercase, 1 digit) |
| name | String | ✗ | `null` | User display name |
| phone | String | ✗ | `null` | Phone number |
| **Authorization** | | | | |
| role | UserRole | ✓ | `USER` | User role (see enum below) |
| status | UserStatus | ✓ | `ACTIVE` | Account status (see enum below) |
| permissions | Json | ✗ | `null` | Granular per-app permissions object |
| **User Lifecycle** | | | | |
| createdVia | UserCreatedVia | ✓ | `STANDARD` | Creation source (see enum below) |
| expiresAt | DateTime | ✗ | `null` | Expiration date for TEMP users |
| expireDays | Int | ✗ | `null` | Days until expiration (for TEMP users) |
| lastLoginAt | DateTime | ✗ | `null` | Last login timestamp |
| emailVerified | Boolean | ✓ | `false` | Email verification status |
| **Audit** | | | | |
| createdAt | DateTime | ✓ | `now()` | Creation timestamp |
| updatedAt | DateTime | ✓ | Auto | Last update timestamp |
### Enums
#### UserRole
Role hierarchy (descending):
```prisma
enum UserRole {
SUPER_ADMIN // Full system access, can manage all users
INFLUENCE_ADMIN // Manage influence module (campaigns, responses)
MAP_ADMIN // Manage map module (locations, shifts, cuts)
USER // Standard volunteer with assigned permissions
TEMP // Temporary user (auto-expires, restricted access)
}
```
**Role Capabilities:**
- **SUPER_ADMIN:** Full access to all modules, user management, settings
- **INFLUENCE_ADMIN:** Campaign CRUD, response moderation, email queue admin
- **MAP_ADMIN:** Location CRUD, shift management, cut creation, canvass oversight
- **USER:** Public campaign actions, shift signup, canvass sessions (via assigned cut)
- **TEMP:** Canvass sessions only (via shift signup), auto-expires after X days
#### UserStatus
```prisma
enum UserStatus {
ACTIVE // Normal active user
INACTIVE // Manually deactivated (login blocked)
SUSPENDED // Temporarily suspended (login blocked)
EXPIRED // Auto-expired temp user (login blocked)
}
```
#### UserCreatedVia
```prisma
enum UserCreatedVia {
ADMIN // Created by admin in user management
PUBLIC_SHIFT_SIGNUP // Auto-created via public shift signup
STANDARD // Self-registered (if enabled)
}
```
### Relations (33 total)
**Authentication:**
- `refreshTokens` → RefreshToken[] (onDelete: Cascade)
**Influence Module (6):**
- `campaignsCreated` → Campaign[] (creator, onDelete: SetNull)
- `campaignEmails` → CampaignEmail[] (sender, onDelete: SetNull)
- `responses` → RepresentativeResponse[] (submitter, onDelete: SetNull)
- `responseUpvotes` → ResponseUpvote[] (onDelete: SetNull)
**Map Module (8):**
- `locationsCreated` → Location[] (creator, onDelete: SetNull)
- `locationsUpdated` → Location[] (updater, onDelete: SetNull)
- `addressesCreated` → Address[] (creator, onDelete: SetNull)
- `addressesUpdated` → Address[] (updater, onDelete: SetNull)
- `locationEdits` → LocationHistory[] (editor, onDelete: SetNull)
- `cutsCreated` → Cut[] (creator, onDelete: SetNull)
- `shiftSignups` → ShiftSignup[] (onDelete: SetNull)
**Canvassing Module (4):**
- `canvassVisits` → CanvassVisit[] (visitor, onDelete: Cascade)
- `canvassSessions` → CanvassSession[] (onDelete: Cascade)
- `trackingSessions` → TrackingSession[] (onDelete: Cascade)
**Email Templates Module (4):**
- `templatesCreated` → EmailTemplate[] (creator)
- `templatesUpdated` → EmailTemplate[] (updater)
- `templateVersionsCreated` → EmailTemplateVersion[]
- `templateTestsSent` → EmailTemplateTestLog[]
### Indexes
- **Unique:** `email` (case-insensitive via Prisma transform)
### Constraints
- Email must be unique across all users
- Password must meet policy: 12+ chars, 1 uppercase, 1 lowercase, 1 digit (enforced by Zod schema)
- TEMP users must have `expiresAt` set
- EXPIRED status auto-applied when `expiresAt` < now()
---
## RefreshToken Model
### Purpose
The RefreshToken model stores JWT refresh tokens for token rotation. When a user logs in, both an access token (15min expiry, stored client-side) and a refresh token (7 day expiry, stored in DB) are issued. When the access token expires, the client uses the refresh token to obtain a new access token. For security, refresh tokens are rotated on each refresh (old token deleted, new token issued) using atomic Prisma transactions.
### Fields
| Field | Type | Required | Default | Description |
|-------|------|----------|---------|-------------|
| id | String | | `cuid()` | Primary key |
| token | String | | | JWT refresh token string (unique, 512 chars) |
| userId | String | | | Foreign key to User |
| expiresAt | DateTime | | | Token expiration timestamp (7 days from issue) |
| createdAt | DateTime | | `now()` | Token creation timestamp |
### Relations
- `user` User (onDelete: Cascade) deleting user deletes all refresh tokens
### Indexes
- **Unique:** `token` (fast lookup for refresh endpoint)
- **Foreign Key:** `userId` (join to User)
### Constraints
- Token must be unique (prevents replay attacks)
- ExpiresAt must be > now() for valid tokens
- Expired tokens cleaned up via cron job (daily)
---
## Relationships Diagram
```mermaid
erDiagram
User ||--o{ RefreshToken : has
User ||--o{ Campaign : creates
User ||--o{ CampaignEmail : sends
User ||--o{ RepresentativeResponse : submits
User ||--o{ ResponseUpvote : upvotes
User ||--o{ ShiftSignup : "signs up for"
User ||--o{ Location : creates
User ||--o{ Location : updates
User ||--o{ Address : "creates (addresses)"
User ||--o{ Address : "updates (addresses)"
User ||--o{ LocationHistory : edits
User ||--o{ Cut : "creates (cuts)"
User ||--o{ CanvassVisit : visits
User ||--o{ CanvassSession : "has (sessions)"
User ||--o{ TrackingSession : "tracks (gps)"
User ||--o{ EmailTemplate : "creates (templates)"
User ||--o{ EmailTemplate : "updates (templates)"
User ||--o{ EmailTemplateVersion : "versions (templates)"
User ||--o{ EmailTemplateTestLog : "tests (templates)"
User {
String id PK
String email UK "unique, lowercase"
String password "bcrypt hashed"
String name
String phone
UserRole role "SUPER_ADMIN | INFLUENCE_ADMIN | MAP_ADMIN | USER | TEMP"
UserStatus status "ACTIVE | INACTIVE | SUSPENDED | EXPIRED"
Json permissions "granular per-app"
UserCreatedVia createdVia
DateTime expiresAt "TEMP user expiration"
Int expireDays
DateTime lastLoginAt
Boolean emailVerified
DateTime createdAt
DateTime updatedAt
}
RefreshToken {
String id PK
String token UK
String userId FK
DateTime expiresAt
DateTime createdAt
}
```
---
## Common Queries
### Create User (Admin)
```typescript
const user = await prisma.user.create({
data: {
email: 'volunteer@example.com',
password: await bcrypt.hash('SecurePass123!', 10),
name: 'Jane Volunteer',
phone: '555-0100',
role: UserRole.USER,
status: UserStatus.ACTIVE,
createdVia: UserCreatedVia.ADMIN,
emailVerified: true,
},
});
```
### Create Temp User (Public Shift Signup)
```typescript
const tempUser = await prisma.user.create({
data: {
email: 'temp@example.com',
password: await bcrypt.hash(randomPassword, 10), // Generated password
name: 'Temp Volunteer',
role: UserRole.TEMP,
status: UserStatus.ACTIVE,
createdVia: UserCreatedVia.PUBLIC_SHIFT_SIGNUP,
expiresAt: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 30 days
expireDays: 30,
},
});
```
### Find User with Relations
```typescript
const user = await prisma.user.findUnique({
where: { email: 'admin@example.com' },
include: {
campaignsCreated: { take: 5, orderBy: { createdAt: 'desc' } },
canvassSessions: { take: 10, orderBy: { startedAt: 'desc' } },
shiftSignups: { include: { shift: true } },
},
});
```
### Update Last Login
```typescript
await prisma.user.update({
where: { id: userId },
data: { lastLoginAt: new Date() },
});
```
### Store Refresh Token
```typescript
const refreshToken = await prisma.refreshToken.create({
data: {
token: jwtRefreshToken,
userId: user.id,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days
},
});
```
### Refresh Token Rotation (Atomic)
```typescript
const newTokens = await prisma.$transaction(async (tx) => {
// 1. Verify old token exists and is valid
const oldToken = await tx.refreshToken.findUnique({
where: { token: oldRefreshToken },
include: { user: true },
});
if (!oldToken || oldToken.expiresAt < new Date()) {
throw new Error('Invalid or expired refresh token');
}
// 2. Delete old token
await tx.refreshToken.delete({
where: { id: oldToken.id },
});
// 3. Generate new access + refresh tokens
const newAccessToken = jwt.sign({ userId: oldToken.userId }, ACCESS_SECRET, { expiresIn: '15m' });
const newRefreshToken = jwt.sign({ userId: oldToken.userId }, REFRESH_SECRET, { expiresIn: '7d' });
// 4. Store new refresh token
await tx.refreshToken.create({
data: {
token: newRefreshToken,
userId: oldToken.userId,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
},
});
return { accessToken: newAccessToken, refreshToken: newRefreshToken };
});
```
### Expire Temp Users (Cron)
```typescript
await prisma.user.updateMany({
where: {
role: UserRole.TEMP,
expiresAt: { lt: new Date() },
status: { not: UserStatus.EXPIRED },
},
data: {
status: UserStatus.EXPIRED,
},
});
```
### Clean Expired Refresh Tokens (Cron)
```typescript
await prisma.refreshToken.deleteMany({
where: {
expiresAt: { lt: new Date() },
},
});
```
---
## Data Flow
### User Registration Flow
```mermaid
sequenceDiagram
participant Client
participant API
participant Prisma
participant bcrypt
participant JWT
Client->>API: POST /api/auth/register (email, password, name)
API->>bcrypt: hash(password)
bcrypt-->>API: hashedPassword
API->>Prisma: user.create({ email, password: hashed, role: USER })
Prisma-->>API: user
API->>JWT: sign(accessToken, { userId, email, role })
API->>JWT: sign(refreshToken, { userId })
JWT-->>API: tokens
API->>Prisma: refreshToken.create({ token, userId, expiresAt })
Prisma-->>API: refreshToken
API-->>Client: { user, accessToken, refreshToken }
```
### Login Flow
```mermaid
sequenceDiagram
participant Client
participant API
participant Prisma
participant bcrypt
participant JWT
Client->>API: POST /api/auth/login (email, password)
API->>Prisma: user.findUnique({ where: { email } })
Prisma-->>API: user | null
API->>bcrypt: compare(password, user.password)
bcrypt-->>API: isValid
alt Invalid credentials
API-->>Client: 401 Unauthorized
else Valid credentials
API->>Prisma: user.update({ lastLoginAt: now() })
API->>JWT: sign(accessToken, { userId, email, role })
API->>JWT: sign(refreshToken, { userId })
JWT-->>API: tokens
API->>Prisma: refreshToken.create({ token, userId, expiresAt })
Prisma-->>API: refreshToken
API-->>Client: { user, accessToken, refreshToken }
end
```
### Token Refresh Flow
```mermaid
sequenceDiagram
participant Client
participant API
participant Prisma
participant JWT
Client->>API: POST /api/auth/refresh (refreshToken)
API->>JWT: verify(refreshToken)
JWT-->>API: payload | error
alt Invalid token
API-->>Client: 401 Unauthorized
else Valid token
API->>Prisma: $transaction start
API->>Prisma: refreshToken.findUnique({ where: { token } })
Prisma-->>API: oldToken | null
alt Token not found or expired
API->>Prisma: $transaction rollback
API-->>Client: 401 Unauthorized
else Token valid
API->>Prisma: refreshToken.delete({ where: { id: oldToken.id } })
API->>JWT: sign(newAccessToken, { userId, email, role })
API->>JWT: sign(newRefreshToken, { userId })
JWT-->>API: newTokens
API->>Prisma: refreshToken.create({ token: newRefreshToken, userId, expiresAt })
API->>Prisma: $transaction commit
Prisma-->>API: success
API-->>Client: { accessToken, refreshToken }
end
end
```
---
## Performance Notes
### Index Usage
- **email unique index:** Used for login lookups (`WHERE email = ?`)
- **refreshToken.token unique index:** Used for refresh endpoint (`WHERE token = ?`)
- **refreshToken.userId index:** Used for user deletion cascades
### Query Optimization
- Avoid loading all 33 user relations by default — use selective `include` or `select`
- Use `findFirst` instead of `findMany().take(1)` for single record queries
- Paginate user lists with `skip` + `take` + cursor-based pagination for large datasets
### N+1 Prevention
```typescript
// ❌ N+1 query (loads campaigns one-by-one)
const users = await prisma.user.findMany();
for (const user of users) {
const campaigns = await prisma.campaign.findMany({ where: { createdByUserId: user.id } });
}
// ✅ Single query with include
const users = await prisma.user.findMany({
include: {
campaignsCreated: true,
},
});
```
---
## Security Considerations
### Password Policy
Enforced at API schema level (`auth.schemas.ts`):
```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')
```
### User Enumeration Prevention
- `/api/auth/me` returns 401 (not 404) for missing users
- Login endpoint returns generic "Invalid credentials" (not "Email not found")
- Registration endpoint returns generic "Email already exists" (no user details)
### Refresh Token Security
- Tokens stored in database (not just signed JWTs)
- Rotation on every refresh (old token deleted)
- Atomic transaction prevents race conditions
- 7-day expiration with daily cleanup cron
### Role-Based Access Control
Middleware enforces role requirements:
```typescript
// Requires SUPER_ADMIN or MAP_ADMIN
router.get('/api/locations', requireRole(UserRole.SUPER_ADMIN, UserRole.MAP_ADMIN), ...)
// Requires any non-TEMP user
router.get('/api/campaigns', requireNonTemp, ...)
// Requires any authenticated user
router.get('/api/profile', authenticate, ...)
```
### TEMP User Restrictions
- Cannot access admin routes (blocked by `requireNonTemp` middleware)
- Cannot create campaigns, locations, or templates
- Can only canvass within assigned cut (verified by canvass service)
- Auto-expire after `expireDays` (default 30)
---
## Troubleshooting
### "Email already exists" on registration
**Cause:** Email uniqueness constraint violated
**Solution:** Check for existing user: `prisma.user.findUnique({ where: { email } })`
### "Invalid refresh token" on refresh
**Cause:** Token already used (rotation), expired, or manually deleted
**Solution:** User must re-login to obtain new token pair
### "Password does not meet policy" on update
**Cause:** Password validation regex mismatch
**Solution:** Ensure new password has 12+ chars, 1 uppercase, 1 lowercase, 1 digit
### TEMP user cannot access route
**Cause:** Route uses `requireNonTemp` middleware
**Solution:** Upgrade user to `USER` role via admin panel
### Circular dependency: auth store ↔ api client
**Cause:** Both modules import each other
**Solution:** Use callback registration pattern (see `admin/src/lib/api.ts` + `admin/src/stores/auth.store.ts`)
---
## Related Documentation
- [Database Overview](../index.md) — Complete ER diagram
- [Schema Reference](../schema.md) — All model fields
- [Influence Models](./influence.md) — Campaign relations
- [Map Models](./map.md) — Location relations
- [Canvassing Models](./canvass.md) — Session relations
- [Email Template Models](./email-templates.md) — Template relations
- [API Auth Routes](../../api/auth.md) — Authentication endpoints
- [Security Audit](../../deployment/security.md) — Security findings and fixes