544 lines
18 KiB
Markdown
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
|