build: production build with Phase 1 2025 Bible Reader implementation complete
Includes all Phase 1 features: - Search-first navigation with auto-complete - Responsive reading interface (desktop/tablet/mobile) - 4 customization presets + full fine-tuning controls - Layered details panel with notes, bookmarks, highlights - Smart offline caching with IndexedDB and auto-sync - Full accessibility (WCAG 2.1 AA) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
907
PAYLOAD_AUTH_MIGRATION_GUIDE.md
Normal file
907
PAYLOAD_AUTH_MIGRATION_GUIDE.md
Normal file
@@ -0,0 +1,907 @@
|
||||
# Payload CMS Authentication Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides detailed steps for migrating from the current JWT-based authentication system to Payload CMS's built-in authentication system while maintaining backward compatibility and ensuring zero downtime.
|
||||
|
||||
## Current Authentication System Analysis
|
||||
|
||||
### Existing Implementation
|
||||
- **Technology**: Custom JWT implementation with bcryptjs
|
||||
- **Token Expiry**: 7 days
|
||||
- **Storage**: PostgreSQL (User, AdminUser tables)
|
||||
- **Roles**: USER, ADMIN, SUPER_ADMIN
|
||||
- **Session Management**: Stateless JWT tokens
|
||||
|
||||
### Current Auth Flow
|
||||
```mermaid
|
||||
graph LR
|
||||
A[User Login] --> B[Validate Credentials]
|
||||
B --> C[Generate JWT]
|
||||
C --> D[Return Token]
|
||||
D --> E[Store in LocalStorage]
|
||||
E --> F[Include in Headers]
|
||||
F --> G[Verify on Each Request]
|
||||
```
|
||||
|
||||
## Payload Authentication System
|
||||
|
||||
### Key Features
|
||||
- **Cookie-based sessions** with HTTP-only cookies
|
||||
- **CSRF protection** built-in
|
||||
- **Refresh tokens** for extended sessions
|
||||
- **Password reset flow** with email verification
|
||||
- **Two-factor authentication** support (optional)
|
||||
- **OAuth providers** integration capability
|
||||
|
||||
### Payload Auth Flow
|
||||
```mermaid
|
||||
graph LR
|
||||
A[User Login] --> B[Validate Credentials]
|
||||
B --> C[Create Session]
|
||||
C --> D[Set HTTP-only Cookie]
|
||||
D --> E[Return User Data]
|
||||
E --> F[Auto-include Cookie]
|
||||
F --> G[Session Validation]
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Phase 1: Dual Authentication Support
|
||||
|
||||
#### Step 1.1: Configure Payload Auth
|
||||
```typescript
|
||||
// config/auth.config.ts
|
||||
export const authConfig = {
|
||||
// Enable both JWT and session-based auth
|
||||
cookies: {
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax' as const,
|
||||
domain: process.env.COOKIE_DOMAIN,
|
||||
},
|
||||
tokenExpiration: 604800, // 7 days (matching current)
|
||||
maxLoginAttempts: 5,
|
||||
lockTime: 600000, // 10 minutes
|
||||
|
||||
// Custom JWT for backward compatibility
|
||||
jwt: {
|
||||
secret: process.env.JWT_SECRET,
|
||||
expiresIn: '7d',
|
||||
},
|
||||
|
||||
// Session configuration
|
||||
session: {
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
secret: process.env.SESSION_SECRET,
|
||||
cookie: {
|
||||
maxAge: 604800000, // 7 days in milliseconds
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
#### Step 1.2: Create Compatibility Layer
|
||||
```typescript
|
||||
// lib/auth/compatibility.ts
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { PayloadRequest } from 'payload/types';
|
||||
|
||||
export class AuthCompatibilityLayer {
|
||||
/**
|
||||
* Validates both old JWT tokens and new Payload sessions
|
||||
*/
|
||||
static async validateRequest(req: PayloadRequest) {
|
||||
// Check for Payload session first
|
||||
if (req.user) {
|
||||
return { valid: true, user: req.user, method: 'payload' };
|
||||
}
|
||||
|
||||
// Check for legacy JWT token
|
||||
const token = this.extractJWT(req);
|
||||
if (token) {
|
||||
try {
|
||||
const decoded = jwt.verify(token, process.env.JWT_SECRET!);
|
||||
const user = await this.getUserFromToken(decoded);
|
||||
return { valid: true, user, method: 'jwt' };
|
||||
} catch (error) {
|
||||
return { valid: false, error: 'Invalid token' };
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: false, error: 'No authentication provided' };
|
||||
}
|
||||
|
||||
private static extractJWT(req: PayloadRequest): string | null {
|
||||
const authHeader = req.headers.authorization;
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
return authHeader.substring(7);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async getUserFromToken(decoded: any) {
|
||||
// Fetch user from Payload collections
|
||||
const user = await payload.findByID({
|
||||
collection: 'users',
|
||||
id: decoded.userId,
|
||||
});
|
||||
return user;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates both JWT (for legacy) and creates Payload session
|
||||
*/
|
||||
static async createDualAuth(user: any, req: PayloadRequest) {
|
||||
// Create Payload session
|
||||
const payloadToken = await payload.login({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: user.email,
|
||||
password: user.password,
|
||||
},
|
||||
req,
|
||||
});
|
||||
|
||||
// Generate legacy JWT
|
||||
const jwtToken = jwt.sign(
|
||||
{
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
},
|
||||
process.env.JWT_SECRET!,
|
||||
{ expiresIn: '7d' }
|
||||
);
|
||||
|
||||
return {
|
||||
payloadToken,
|
||||
jwtToken, // For backward compatibility
|
||||
user,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: User Migration
|
||||
|
||||
#### Step 2.1: User Data Migration Script
|
||||
```typescript
|
||||
// scripts/migrate-users.ts
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
import payload from 'payload';
|
||||
import bcrypt from 'bcryptjs';
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
interface MigrationResult {
|
||||
success: number;
|
||||
failed: number;
|
||||
errors: Array<{ email: string; error: string }>;
|
||||
}
|
||||
|
||||
export async function migrateUsers(): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
success: 0,
|
||||
failed: 0,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Fetch all users from Prisma
|
||||
const users = await prisma.user.findMany({
|
||||
include: {
|
||||
subscription: true,
|
||||
userSettings: true,
|
||||
bookmarks: true,
|
||||
highlights: true,
|
||||
},
|
||||
});
|
||||
|
||||
console.log(`Starting migration of ${users.length} users...`);
|
||||
|
||||
for (const user of users) {
|
||||
try {
|
||||
// Check if user already exists in Payload
|
||||
const existing = await payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
email: { equals: user.email },
|
||||
},
|
||||
});
|
||||
|
||||
if (existing.docs.length > 0) {
|
||||
console.log(`User ${user.email} already migrated, skipping...`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Create user in Payload
|
||||
const payloadUser = await payload.create({
|
||||
collection: 'users',
|
||||
data: {
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role,
|
||||
|
||||
// Password handling - already hashed
|
||||
password: user.password,
|
||||
_verified: true, // Mark as verified
|
||||
|
||||
// Custom fields
|
||||
stripeCustomerId: user.stripeCustomerId,
|
||||
favoriteVersion: user.favoriteVersion || 'VDC',
|
||||
|
||||
// Settings
|
||||
profileSettings: {
|
||||
fontSize: user.userSettings?.fontSize || 16,
|
||||
theme: user.userSettings?.theme || 'light',
|
||||
showVerseNumbers: user.userSettings?.showVerseNumbers ?? true,
|
||||
enableNotifications: user.userSettings?.enableNotifications ?? true,
|
||||
},
|
||||
|
||||
// Timestamps
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
lastLogin: user.lastLogin,
|
||||
},
|
||||
});
|
||||
|
||||
// Migrate related data
|
||||
if (user.subscription) {
|
||||
await migrateUserSubscription(payloadUser.id, user.subscription);
|
||||
}
|
||||
|
||||
if (user.bookmarks.length > 0) {
|
||||
await migrateUserBookmarks(payloadUser.id, user.bookmarks);
|
||||
}
|
||||
|
||||
if (user.highlights.length > 0) {
|
||||
await migrateUserHighlights(payloadUser.id, user.highlights);
|
||||
}
|
||||
|
||||
result.success++;
|
||||
console.log(`✓ Migrated user: ${user.email}`);
|
||||
|
||||
} catch (error) {
|
||||
result.failed++;
|
||||
result.errors.push({
|
||||
email: user.email,
|
||||
error: error.message,
|
||||
});
|
||||
console.error(`✗ Failed to migrate user ${user.email}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async function migrateUserSubscription(userId: string, subscription: any) {
|
||||
await payload.create({
|
||||
collection: 'subscriptions',
|
||||
data: {
|
||||
user: userId,
|
||||
stripeSubscriptionId: subscription.stripeSubscriptionId,
|
||||
planName: subscription.planName,
|
||||
status: subscription.status,
|
||||
currentPeriodStart: subscription.currentPeriodStart,
|
||||
currentPeriodEnd: subscription.currentPeriodEnd,
|
||||
conversationCount: subscription.conversationCount,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function migrateUserBookmarks(userId: string, bookmarks: any[]) {
|
||||
for (const bookmark of bookmarks) {
|
||||
await payload.create({
|
||||
collection: 'bookmarks',
|
||||
data: {
|
||||
user: userId,
|
||||
book: bookmark.book,
|
||||
chapter: bookmark.chapter,
|
||||
verse: bookmark.verse,
|
||||
note: bookmark.note,
|
||||
createdAt: bookmark.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateUserHighlights(userId: string, highlights: any[]) {
|
||||
for (const highlight of highlights) {
|
||||
await payload.create({
|
||||
collection: 'highlights',
|
||||
data: {
|
||||
user: userId,
|
||||
verseId: highlight.verseId,
|
||||
color: highlight.color,
|
||||
note: highlight.note,
|
||||
createdAt: highlight.createdAt,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 2.2: Password Migration Strategy
|
||||
|
||||
Since passwords are already hashed with bcrypt, we have three options:
|
||||
|
||||
**Option 1: Direct Hash Migration (Recommended)**
|
||||
```typescript
|
||||
// hooks/auth.hooks.ts
|
||||
export const passwordValidationHook = {
|
||||
beforeOperation: async ({ args, operation }) => {
|
||||
if (operation === 'login') {
|
||||
const { email, password } = args.data;
|
||||
|
||||
// Find user
|
||||
const user = await payload.find({
|
||||
collection: 'users',
|
||||
where: { email: { equals: email } },
|
||||
});
|
||||
|
||||
if (user.docs.length === 0) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
const userDoc = user.docs[0];
|
||||
|
||||
// Check if password needs rehashing (migrated user)
|
||||
if (userDoc.passwordMigrated) {
|
||||
// Use bcrypt directly for migrated passwords
|
||||
const valid = await bcrypt.compare(password, userDoc.password);
|
||||
if (!valid) {
|
||||
throw new Error('Invalid credentials');
|
||||
}
|
||||
|
||||
// Rehash with Payload's method on successful login
|
||||
await payload.update({
|
||||
collection: 'users',
|
||||
id: userDoc.id,
|
||||
data: {
|
||||
password, // Will be hashed by Payload
|
||||
passwordMigrated: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
**Option 2: Password Reset Campaign**
|
||||
```typescript
|
||||
// scripts/password-reset-campaign.ts
|
||||
export async function sendPasswordResetToMigratedUsers() {
|
||||
const migratedUsers = await payload.find({
|
||||
collection: 'users',
|
||||
where: {
|
||||
passwordMigrated: { equals: true },
|
||||
},
|
||||
});
|
||||
|
||||
for (const user of migratedUsers.docs) {
|
||||
const token = await payload.forgotPassword({
|
||||
collection: 'users',
|
||||
data: { email: user.email },
|
||||
disableEmail: false,
|
||||
});
|
||||
|
||||
// Send custom email explaining migration
|
||||
await sendMigrationEmail({
|
||||
to: user.email,
|
||||
token,
|
||||
userName: user.name,
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: API Endpoint Migration
|
||||
|
||||
#### Step 3.1: Update Frontend API Calls
|
||||
```typescript
|
||||
// lib/api/auth.ts (Frontend)
|
||||
export class AuthAPI {
|
||||
private static baseURL = process.env.NEXT_PUBLIC_API_URL;
|
||||
|
||||
static async login(email: string, password: string) {
|
||||
try {
|
||||
// Try new Payload endpoint first
|
||||
const response = await fetch(`${this.baseURL}/api/users/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include', // Important for cookies
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
// Store JWT for backward compatibility if provided
|
||||
if (data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
}
|
||||
|
||||
return { success: true, user: data.user };
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Payload login failed, trying legacy...', error);
|
||||
}
|
||||
|
||||
// Fallback to legacy endpoint
|
||||
return this.legacyLogin(email, password);
|
||||
}
|
||||
|
||||
private static async legacyLogin(email: string, password: string) {
|
||||
const response = await fetch(`${this.baseURL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
if (data.token) {
|
||||
localStorage.setItem('token', data.token);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
static async logout() {
|
||||
// Clear both Payload session and JWT
|
||||
await fetch(`${this.baseURL}/api/users/logout`, {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
localStorage.removeItem('token');
|
||||
}
|
||||
|
||||
static async getMe() {
|
||||
// Try Payload endpoint with cookie
|
||||
try {
|
||||
const response = await fetch(`${this.baseURL}/api/users/me`, {
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to JWT
|
||||
const token = localStorage.getItem('token');
|
||||
if (token) {
|
||||
const response = await fetch(`${this.baseURL}/api/auth/me`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
return response.json();
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Not authenticated');
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 3.2: Update API Middleware
|
||||
```typescript
|
||||
// middleware/auth.middleware.ts
|
||||
import { PayloadRequest } from 'payload/types';
|
||||
import { AuthCompatibilityLayer } from '../lib/auth/compatibility';
|
||||
|
||||
export async function authMiddleware(req: PayloadRequest, res: any, next: any) {
|
||||
const auth = await AuthCompatibilityLayer.validateRequest(req);
|
||||
|
||||
if (!auth.valid) {
|
||||
return res.status(401).json({ error: auth.error });
|
||||
}
|
||||
|
||||
// Attach user to request for both auth methods
|
||||
req.user = auth.user;
|
||||
req.authMethod = auth.method; // Track which auth method was used
|
||||
|
||||
// Log for monitoring during migration
|
||||
console.log(`Auth method: ${auth.method} for user: ${auth.user.email}`);
|
||||
|
||||
next();
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: Testing & Validation
|
||||
|
||||
#### Step 4.1: Authentication Test Suite
|
||||
```typescript
|
||||
// tests/auth/migration.test.ts
|
||||
import { describe, it, expect, beforeAll, afterAll } from '@jest/globals';
|
||||
import payload from 'payload';
|
||||
import { testUser } from '../fixtures/users';
|
||||
|
||||
describe('Authentication Migration Tests', () => {
|
||||
beforeAll(async () => {
|
||||
await payload.init({
|
||||
local: true,
|
||||
secret: 'test-secret',
|
||||
});
|
||||
});
|
||||
|
||||
describe('Dual Authentication', () => {
|
||||
it('should accept legacy JWT tokens', async () => {
|
||||
const token = generateLegacyJWT(testUser);
|
||||
const response = await fetch('/api/protected', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should accept Payload session cookies', async () => {
|
||||
const loginResponse = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: testUser.email,
|
||||
password: testUser.password,
|
||||
}),
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
const cookie = loginResponse.headers.get('set-cookie');
|
||||
|
||||
const response = await fetch('/api/protected', {
|
||||
headers: {
|
||||
'Cookie': cookie,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('should migrate password on first login', async () => {
|
||||
const migratedUser = await createMigratedUser();
|
||||
|
||||
const response = await fetch('/api/users/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
email: migratedUser.email,
|
||||
password: 'original-password',
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
// Check that password was rehashed
|
||||
const user = await payload.findByID({
|
||||
collection: 'users',
|
||||
id: migratedUser.id,
|
||||
});
|
||||
|
||||
expect(user.passwordMigrated).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Session Management', () => {
|
||||
it('should maintain session across requests', async () => {
|
||||
const session = await createAuthSession(testUser);
|
||||
|
||||
// Make multiple requests with same session
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const response = await fetch('/api/protected', {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
}
|
||||
});
|
||||
|
||||
it('should refresh token before expiry', async () => {
|
||||
const session = await createAuthSession(testUser);
|
||||
|
||||
// Fast-forward time to near expiry
|
||||
jest.advanceTimersByTime(6 * 24 * 60 * 60 * 1000); // 6 days
|
||||
|
||||
const response = await fetch('/api/users/refresh', {
|
||||
headers: {
|
||||
'Cookie': session.cookie,
|
||||
},
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const newCookie = response.headers.get('set-cookie');
|
||||
expect(newCookie).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Role-Based Access', () => {
|
||||
it('should enforce admin access', async () => {
|
||||
const regularUser = await createUser({ role: 'USER' });
|
||||
const adminUser = await createUser({ role: 'ADMIN' });
|
||||
|
||||
const regularSession = await createAuthSession(regularUser);
|
||||
const adminSession = await createAuthSession(adminUser);
|
||||
|
||||
// Regular user should be denied
|
||||
const regularResponse = await fetch('/api/admin/users', {
|
||||
headers: { 'Cookie': regularSession.cookie },
|
||||
});
|
||||
expect(regularResponse.status).toBe(403);
|
||||
|
||||
// Admin should be allowed
|
||||
const adminResponse = await fetch('/api/admin/users', {
|
||||
headers: { 'Cookie': adminSession.cookie },
|
||||
});
|
||||
expect(adminResponse.status).toBe(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
#### Step 4.2: Migration Validation Script
|
||||
```typescript
|
||||
// scripts/validate-migration.ts
|
||||
export async function validateMigration() {
|
||||
const report = {
|
||||
users: { total: 0, migrated: 0, failed: [] },
|
||||
auth: { jwt: 0, payload: 0, dual: 0 },
|
||||
subscriptions: { total: 0, active: 0, cancelled: 0 },
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Check user migration
|
||||
const prismaUsers = await prisma.user.count();
|
||||
const payloadUsers = await payload.count({ collection: 'users' });
|
||||
|
||||
report.users.total = prismaUsers;
|
||||
report.users.migrated = payloadUsers.totalDocs;
|
||||
|
||||
// Test authentication methods
|
||||
const testResults = await testAuthenticationMethods();
|
||||
report.auth = testResults;
|
||||
|
||||
// Validate subscriptions
|
||||
const subscriptions = await validateSubscriptions();
|
||||
report.subscriptions = subscriptions;
|
||||
|
||||
// Generate report
|
||||
console.log('Migration Validation Report:');
|
||||
console.log('============================');
|
||||
console.log(`Users: ${report.users.migrated}/${report.users.total} migrated`);
|
||||
console.log(`Auth Methods: JWT: ${report.auth.jwt}, Payload: ${report.auth.payload}`);
|
||||
console.log(`Subscriptions: ${report.subscriptions.active} active`);
|
||||
|
||||
if (report.errors.length > 0) {
|
||||
console.log('\nErrors found:');
|
||||
report.errors.forEach(error => console.error(error));
|
||||
}
|
||||
|
||||
return report;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Gradual Rollout
|
||||
|
||||
#### Step 5.1: Feature Flags
|
||||
```typescript
|
||||
// lib/features/flags.ts
|
||||
export const AuthFeatureFlags = {
|
||||
USE_PAYLOAD_AUTH: process.env.NEXT_PUBLIC_USE_PAYLOAD_AUTH === 'true',
|
||||
DUAL_AUTH_MODE: process.env.NEXT_PUBLIC_DUAL_AUTH === 'true',
|
||||
FORCE_PASSWORD_RESET: process.env.FORCE_PASSWORD_RESET === 'true',
|
||||
};
|
||||
|
||||
// Usage in components
|
||||
export function LoginForm() {
|
||||
const handleSubmit = async (data: LoginData) => {
|
||||
if (AuthFeatureFlags.USE_PAYLOAD_AUTH) {
|
||||
return payloadLogin(data);
|
||||
} else if (AuthFeatureFlags.DUAL_AUTH_MODE) {
|
||||
return dualLogin(data);
|
||||
} else {
|
||||
return legacyLogin(data);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 5.2: A/B Testing
|
||||
```typescript
|
||||
// lib/ab-testing/auth.ts
|
||||
export function getAuthStrategy(userId?: string): 'legacy' | 'payload' | 'dual' {
|
||||
// Percentage-based rollout
|
||||
const rolloutPercentage = parseInt(process.env.PAYLOAD_AUTH_ROLLOUT || '0');
|
||||
|
||||
if (!userId) {
|
||||
// New users always get Payload auth
|
||||
return 'payload';
|
||||
}
|
||||
|
||||
// Consistent assignment based on user ID
|
||||
const hash = hashUserId(userId);
|
||||
const bucket = hash % 100;
|
||||
|
||||
if (bucket < rolloutPercentage) {
|
||||
return 'payload';
|
||||
} else if (process.env.DUAL_AUTH === 'true') {
|
||||
return 'dual';
|
||||
} else {
|
||||
return 'legacy';
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 6: Monitoring & Observability
|
||||
|
||||
#### Step 6.1: Authentication Metrics
|
||||
```typescript
|
||||
// lib/monitoring/auth-metrics.ts
|
||||
import { metrics } from '@opentelemetry/api-metrics';
|
||||
|
||||
export class AuthMetrics {
|
||||
private meter = metrics.getMeter('auth-migration');
|
||||
private loginCounter = this.meter.createCounter('auth_login_total');
|
||||
private methodHistogram = this.meter.createHistogram('auth_method_duration');
|
||||
private failureCounter = this.meter.createCounter('auth_failure_total');
|
||||
|
||||
trackLogin(method: 'jwt' | 'payload' | 'dual', success: boolean, duration: number) {
|
||||
this.loginCounter.add(1, {
|
||||
method,
|
||||
success: success.toString(),
|
||||
});
|
||||
|
||||
this.methodHistogram.record(duration, {
|
||||
method,
|
||||
});
|
||||
|
||||
if (!success) {
|
||||
this.failureCounter.add(1, { method });
|
||||
}
|
||||
}
|
||||
|
||||
async generateReport() {
|
||||
return {
|
||||
totalLogins: await this.getTotalLogins(),
|
||||
methodDistribution: await this.getMethodDistribution(),
|
||||
failureRate: await this.getFailureRate(),
|
||||
avgDuration: await this.getAverageDuration(),
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Step 6.2: Monitoring Dashboard
|
||||
```typescript
|
||||
// components/admin/AuthMigrationDashboard.tsx
|
||||
export function AuthMigrationDashboard() {
|
||||
const [metrics, setMetrics] = useState<AuthMetrics>();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchMetrics = async () => {
|
||||
const data = await fetch('/api/admin/auth-metrics').then(r => r.json());
|
||||
setMetrics(data);
|
||||
};
|
||||
|
||||
fetchMetrics();
|
||||
const interval = setInterval(fetchMetrics, 30000); // Update every 30s
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<h2>Authentication Migration Status</h2>
|
||||
|
||||
<div className="metrics-grid">
|
||||
<MetricCard
|
||||
title="Auth Method Distribution"
|
||||
value={
|
||||
<PieChart data={[
|
||||
{ name: 'JWT', value: metrics?.jwt || 0 },
|
||||
{ name: 'Payload', value: metrics?.payload || 0 },
|
||||
{ name: 'Dual', value: metrics?.dual || 0 },
|
||||
]} />
|
||||
}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Migration Progress"
|
||||
value={`${metrics?.migratedUsers || 0} / ${metrics?.totalUsers || 0}`}
|
||||
subtitle={`${Math.round((metrics?.migratedUsers / metrics?.totalUsers) * 100)}% complete`}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Auth Success Rate"
|
||||
value={`${metrics?.successRate || 0}%`}
|
||||
trend={metrics?.successTrend}
|
||||
/>
|
||||
|
||||
<MetricCard
|
||||
title="Active Sessions"
|
||||
value={metrics?.activeSessions || 0}
|
||||
subtitle={`JWT: ${metrics?.jwtSessions}, Payload: ${metrics?.payloadSessions}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="recent-issues">
|
||||
<h3>Recent Authentication Issues</h3>
|
||||
<IssuesList issues={metrics?.recentIssues || []} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Rollback Procedures
|
||||
|
||||
### Emergency Rollback Script
|
||||
```typescript
|
||||
// scripts/auth-rollback.ts
|
||||
export async function rollbackAuth() {
|
||||
console.log('Starting authentication rollback...');
|
||||
|
||||
// 1. Disable Payload auth endpoints
|
||||
await updateEnvironmentVariable('USE_PAYLOAD_AUTH', 'false');
|
||||
|
||||
// 2. Re-enable legacy endpoints
|
||||
await updateEnvironmentVariable('USE_LEGACY_AUTH', 'true');
|
||||
|
||||
// 3. Clear Payload sessions
|
||||
await payload.delete({
|
||||
collection: 'sessions',
|
||||
where: {},
|
||||
});
|
||||
|
||||
// 4. Notify users
|
||||
await sendSystemNotification({
|
||||
message: 'Authentication system maintenance in progress',
|
||||
type: 'warning',
|
||||
});
|
||||
|
||||
// 5. Monitor legacy auth performance
|
||||
startLegacyAuthMonitoring();
|
||||
|
||||
console.log('Rollback complete. Legacy auth restored.');
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices & Recommendations
|
||||
|
||||
### Security Considerations
|
||||
1. **Never log passwords** in any form
|
||||
2. **Use HTTPS only** for production
|
||||
3. **Implement rate limiting** on auth endpoints
|
||||
4. **Monitor failed login attempts**
|
||||
5. **Regular security audits** of auth flows
|
||||
|
||||
### Performance Optimization
|
||||
1. **Cache user sessions** in Redis
|
||||
2. **Implement session pooling**
|
||||
3. **Use database indexes** on email fields
|
||||
4. **Lazy-load user relationships**
|
||||
5. **CDN for static auth assets**
|
||||
|
||||
### User Experience
|
||||
1. **Transparent migration** - users shouldn't notice
|
||||
2. **Clear error messages** for auth failures
|
||||
3. **Password strength indicators**
|
||||
4. **Remember me functionality**
|
||||
5. **Social login options** (future enhancement)
|
||||
|
||||
## Conclusion
|
||||
|
||||
The migration to Payload CMS authentication provides:
|
||||
- **Enhanced security** with HTTP-only cookies and CSRF protection
|
||||
- **Better session management** with automatic refresh
|
||||
- **Simplified codebase** with less custom auth code
|
||||
- **Future-proof architecture** for OAuth and 2FA
|
||||
|
||||
The dual-authentication approach ensures zero downtime and allows for gradual migration with full rollback capability.
|
||||
|
||||
---
|
||||
|
||||
*Document Version: 1.0*
|
||||
*Last Updated: November 2024*
|
||||
*Author: Biblical Guide Development Team*
|
||||
Reference in New Issue
Block a user