CalDAV: Support Basic auth; trim+validate UUID; deprecate query token via headers; ICS end time helper; docs+instructions updated
This commit is contained in:
121
docs/caldav-setup.md
Normal file
121
docs/caldav-setup.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# CalDAV-Kalender Einrichtung
|
||||
|
||||
## Übersicht
|
||||
|
||||
Die App bietet einen CalDAV-Endpunkt, um Buchungen in externe Kalender-Apps zu synchronisieren. Aus Sicherheitsgründen erfolgt die Authentifizierung per Header. Unterstützt werden:
|
||||
|
||||
- Authorization: Bearer <TOKEN>
|
||||
- Basic Auth, wobei der Token als Benutzername übergeben wird (Passwort leer/optional)
|
||||
|
||||
## Token generieren
|
||||
|
||||
1. Als Admin einloggen
|
||||
2. Im Admin-Bereich den CalDAV-Token generieren
|
||||
3. Token und URL werden angezeigt
|
||||
|
||||
**Wichtig:** Der Token ist 24 Stunden gültig. Danach muss ein neuer Token generiert werden.
|
||||
|
||||
## Endpunkt
|
||||
|
||||
```
|
||||
GET /caldav/calendar/events.ics
|
||||
Authorization: Bearer <TOKEN>
|
||||
|
||||
oder
|
||||
|
||||
Authorization: Basic <base64(token:)> # Token als Benutzername, Passwort leer
|
||||
```
|
||||
|
||||
## Unterstützte Kalender-Apps
|
||||
|
||||
### ✅ Thunderbird (empfohlen)
|
||||
|
||||
1. Kalender → Neuer Kalender → Im Netzwerk
|
||||
2. Format: CalDAV
|
||||
3. Standort: CalDAV-URL eingeben
|
||||
4. Benutzername: Den generierten Token eingeben (Basic Auth)
|
||||
5. Passwort: Leer lassen
|
||||
|
||||
### ✅ Outlook (mit Einschränkungen)
|
||||
|
||||
1. Datei → Kontoeinstellungen → Internetkalender
|
||||
2. URL eingeben
|
||||
3. Erweiterte Einstellungen → Benutzerdefinierte Header:
|
||||
```
|
||||
Authorization: Bearer <TOKEN>
|
||||
```
|
||||
|
||||
**Hinweis:** Nicht alle Outlook-Versionen unterstützen benutzerdefinierte Header.
|
||||
|
||||
### ⚠️ Apple Calendar (eingeschränkt)
|
||||
|
||||
Apple Calendar unterstützt keine Authorization-Header für Kalenderabonnements.
|
||||
|
||||
**Alternativen:**
|
||||
- Manuelle ICS-Datei importieren (nicht automatisch aktualisiert)
|
||||
- CalDAV-Bridge verwenden (z.B. über Proxy)
|
||||
|
||||
### ⚠️ Google Calendar (eingeschränkt)
|
||||
|
||||
Google Calendar unterstützt keine Authorization-Header für URL-Abonnements.
|
||||
|
||||
**Alternativen:**
|
||||
- Google Apps Script als Bridge verwenden
|
||||
- Manuelle ICS-Datei importieren
|
||||
|
||||
## Testen mit cURL
|
||||
|
||||
```bash
|
||||
# Bearer
|
||||
curl -H "Authorization: Bearer <DEIN_TOKEN>" \
|
||||
https://deine-domain.de/caldav/calendar/events.ics
|
||||
|
||||
# Basic (Token als Benutzername, Passwort leer)
|
||||
curl -H "Authorization: Basic $(printf "%s" "<DEIN_TOKEN>:" | base64)" \
|
||||
https://deine-domain.de/caldav/calendar/events.ics
|
||||
```
|
||||
|
||||
Erwartete Antwort: ICS-Datei mit allen bestätigten und ausstehenden Buchungen.
|
||||
|
||||
## Sicherheit
|
||||
|
||||
### Warum Authorization-Header?
|
||||
|
||||
- **Query-Parameter** (`?token=...`) werden in Browser-History, Server-Logs und Referrer-Headers gespeichert
|
||||
- **Authorization-Header** werden nicht geloggt und nicht in der URL sichtbar
|
||||
- Folgt REST-API Best Practices
|
||||
|
||||
### Token-Verwaltung
|
||||
|
||||
- Token sind 24 Stunden gültig
|
||||
- Abgelaufene Token werden automatisch beim nächsten Zugriff gelöscht
|
||||
- Bei Bedarf neuen Token generieren (alte Token werden nicht automatisch invalidiert)
|
||||
|
||||
### Backward Compatibility
|
||||
|
||||
Die alte Methode mit Query-Parameter (`?token=...`) wird noch unterstützt, aber als **deprecated** markiert. Der Server sendet zusätzlich Response-Header (`Deprecation: true` und `Warning: 299 ...`). Eine Warnung wird im Server-Log ausgegeben.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Unauthorized - Token required"
|
||||
|
||||
- Prüfe, ob der Authorization-Header korrekt gesetzt ist
|
||||
- Format: `Authorization: Bearer <TOKEN>` (mit Leerzeichen nach "Bearer")
|
||||
|
||||
### "Unauthorized - Invalid or expired token"
|
||||
|
||||
- Token ist abgelaufen (24h Gültigkeit)
|
||||
- Generiere einen neuen Token im Admin-Bereich
|
||||
|
||||
### Kalender zeigt keine Termine
|
||||
|
||||
- Prüfe, ob Buchungen mit Status "confirmed" oder "pending" existieren
|
||||
- Teste den Endpunkt mit cURL
|
||||
- Prüfe Server-Logs auf Fehler
|
||||
|
||||
## Zukünftige Verbesserungen
|
||||
|
||||
- [ ] Langlebige Token mit Refresh-Mechanismus
|
||||
- [ ] Token-Revocation-Endpoint
|
||||
- [ ] CalDAV-Bridge für Apple Calendar und Google Calendar
|
||||
- [ ] Webhook-basierte Push-Notifications statt Polling
|
@@ -37,17 +37,38 @@ Das System verwendet ein Rate-Limiting, um Spam und Missbrauch des Buchungsformu
|
||||
- **Zeitfenster:** 10 Minuten
|
||||
- **Verhalten:** Nach 5 Anfragen muss der Nutzer 10 Minuten warten
|
||||
|
||||
### Login (IP-basiert)
|
||||
- **Limit:** 5 fehlgeschlagene Login-Versuche pro IP-Adresse
|
||||
- **Zeitfenster:** 15 Minuten
|
||||
- **Verhalten:** Das Limit zählt nur fehlgeschlagene Versuche. Nach erfolgreichem Login wird der Zähler zurückgesetzt. Bei Überschreitung muss der Nutzer 15 Minuten warten.
|
||||
- **Zweck:** Schutz vor Brute-Force-Angriffen auf Admin-Accounts
|
||||
|
||||
### Admin-Operationen (Benutzer-basiert)
|
||||
- **Limit:** 30 Anfragen pro Admin-Benutzer
|
||||
- **Zeitfenster:** 5 Minuten
|
||||
- **Verhalten:** Nach 30 Anfragen muss der Admin 5 Minuten warten
|
||||
- **Zweck:** Verhindert versehentliches Spam durch Admin-UI (z.B. Doppelklicks, fehlerhafte Skripte)
|
||||
- **Betroffene Endpoints:** Treatments (create/update/remove), Bookings (manualCreate/updateStatus/remove), Recurring Availability (alle Schreiboperationen), Gallery (alle Schreiboperationen), Reviews (approve/reject/delete)
|
||||
|
||||
### Admin-Operationen (IP-basiert)
|
||||
- **Limit:** 50 Anfragen pro IP-Adresse
|
||||
- **Zeitfenster:** 5 Minuten
|
||||
- **Verhalten:** Nach 50 Anfragen muss 5 Minuten gewartet werden
|
||||
- **Zweck:** Zusätzlicher Schutz gegen IP-basierte Angriffe auf Admin-Endpoints
|
||||
|
||||
## Wie es funktioniert
|
||||
|
||||
Das Rate-Limiting prüft **beide** Kriterien:
|
||||
Das Rate-Limiting prüft die passenden Kriterien je Endpoint. Für Admin-Operationen werden **beide** Limits geprüft:
|
||||
1. **E-Mail-Adresse:** Verhindert, dass dieselbe Person mit derselben E-Mail zu viele Anfragen stellt
|
||||
2. **IP-Adresse:** Verhindert, dass jemand mit verschiedenen E-Mail-Adressen von derselben IP aus spammt
|
||||
3. **Benutzer-basiert (Admin):** Limitiert Anfragen je Admin-Benutzer
|
||||
4. **IP-basiert (Admin):** Limitiert Anfragen je IP zusätzlich
|
||||
|
||||
Wenn eines der Limits überschritten wird, erhält der Nutzer eine Fehlermeldung mit Angabe der Wartezeit.
|
||||
|
||||
## IP-Erkennung
|
||||
|
||||
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers:
|
||||
Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch folgende Headers (unterstützt `Headers`-API und einfache Record-Objekte):
|
||||
- `x-forwarded-for`
|
||||
- `x-real-ip`
|
||||
- `cf-connecting-ip` (Cloudflare)
|
||||
@@ -60,7 +81,7 @@ Das System erkennt die Client-IP auch hinter Proxies und Load Balancern durch fo
|
||||
|
||||
## Anpassung
|
||||
|
||||
Die Limits können in `src/server/lib/rate-limiter.ts` in der Funktion `checkBookingRateLimit()` angepasst werden:
|
||||
Die Limits können in `src/server/lib/rate-limiter.ts` angepasst werden. Beispiele:
|
||||
|
||||
```typescript
|
||||
// E-Mail-Limit anpassen
|
||||
@@ -74,6 +95,23 @@ const ipConfig: RateLimitConfig = {
|
||||
maxRequests: 5, // Anzahl der Anfragen
|
||||
windowMs: 10 * 60 * 1000, // Zeitfenster in Millisekunden
|
||||
};
|
||||
|
||||
// Login-Bruteforce-Schutz
|
||||
const loginConfig: RateLimitConfig = {
|
||||
maxRequests: 5,
|
||||
windowMs: 15 * 60 * 1000,
|
||||
};
|
||||
|
||||
// Admin-Operationen
|
||||
const adminUserConfig: RateLimitConfig = {
|
||||
maxRequests: 30,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
};
|
||||
|
||||
const adminIpConfig: RateLimitConfig = {
|
||||
maxRequests: 50,
|
||||
windowMs: 5 * 60 * 1000,
|
||||
};
|
||||
```
|
||||
|
||||
## Fehlermeldungen
|
||||
@@ -91,3 +129,5 @@ Für Produktionsumgebungen empfehlen sich:
|
||||
- ✅ Whitelist für vertrauenswürdige IPs (z.B. Admin-Zugang)
|
||||
- ✅ Anpassung der Limits basierend auf tatsächlichem Nutzungsverhalten
|
||||
|
||||
Siehe auch `docs/redis-migration.md` für Hinweise zur Migration auf Redis in Multi-Instance-Setups.
|
||||
|
||||
|
355
docs/redis-migration.md
Normal file
355
docs/redis-migration.md
Normal file
@@ -0,0 +1,355 @@
|
||||
# Redis Migration Guide
|
||||
|
||||
## Overview
|
||||
|
||||
This guide covers migrating from in-memory KV storage to Redis for multi-instance SaaS deployments. The current in-memory session storage works well for single-instance deployments but requires centralized storage for horizontal scaling.
|
||||
|
||||
### When to Migrate
|
||||
|
||||
**Current Setup (Sufficient For):**
|
||||
- Single-instance deployments
|
||||
- Development environments
|
||||
- Small-scale production deployments
|
||||
|
||||
**Redis Required For:**
|
||||
- Multiple server instances behind load balancer
|
||||
- Container orchestration (Kubernetes, Docker Swarm)
|
||||
- High-availability SaaS deployments
|
||||
- Session persistence across server restarts
|
||||
|
||||
## What Needs Migration
|
||||
|
||||
### Critical Data (Must Migrate)
|
||||
- **Sessions** (`sessionsKV`): Essential for authentication across instances
|
||||
- **CSRF tokens**: Stored within session objects
|
||||
- **Rate limiting data**: Currently in-memory Map in `rate-limiter.ts`
|
||||
|
||||
### Optional Data (Can Remain File-based)
|
||||
- Bookings, treatments, reviews, gallery photos
|
||||
- Can stay in file-based KV for now
|
||||
- Migrate later if performance becomes an issue
|
||||
|
||||
## Redis Setup
|
||||
|
||||
### Installation Options
|
||||
|
||||
#### Self-hosted Redis
|
||||
```bash
|
||||
# Docker
|
||||
docker run -d -p 6379:6379 redis:alpine
|
||||
|
||||
# Ubuntu/Debian
|
||||
sudo apt update
|
||||
sudo apt install redis-server
|
||||
|
||||
# macOS (Homebrew)
|
||||
brew install redis
|
||||
brew services start redis
|
||||
```
|
||||
|
||||
#### Managed Services
|
||||
- **Redis Cloud**: Managed Redis service
|
||||
- **AWS ElastiCache**: Redis-compatible cache
|
||||
- **Azure Cache for Redis**: Microsoft's managed service
|
||||
- **DigitalOcean Managed Databases**: Redis option available
|
||||
|
||||
### Configuration
|
||||
|
||||
Add to `.env`:
|
||||
```bash
|
||||
# Redis Configuration (required for multi-instance deployments)
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_TLS_ENABLED=false # Set to true for production
|
||||
REDIS_PASSWORD=your_redis_password # Optional
|
||||
```
|
||||
|
||||
## Code Changes
|
||||
|
||||
### 1. Install Redis Client
|
||||
|
||||
```bash
|
||||
pnpm add ioredis
|
||||
pnpm add -D @types/ioredis
|
||||
```
|
||||
|
||||
### 2. Create Redis KV Adapter
|
||||
|
||||
Create `src/server/lib/create-redis-kv.ts`:
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
|
||||
interface RedisKVStore<T> {
|
||||
getItem(key: string): Promise<T | null>;
|
||||
setItem(key: string, value: T): Promise<void>;
|
||||
removeItem(key: string): Promise<void>;
|
||||
getAllItems(): Promise<T[]>;
|
||||
subscribe(): AsyncIterable<void>;
|
||||
}
|
||||
|
||||
export function createRedisKV<T>(namespace: string): RedisKVStore<T> {
|
||||
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||||
retryDelayOnFailover: 100,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: null,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
const prefix = `${namespace}:`;
|
||||
|
||||
return {
|
||||
async getItem(key: string): Promise<T | null> {
|
||||
const value = await redis.get(`${prefix}${key}`);
|
||||
return value ? JSON.parse(value) : null;
|
||||
},
|
||||
|
||||
async setItem(key: string, value: T): Promise<void> {
|
||||
await redis.set(`${prefix}${key}`, JSON.stringify(value));
|
||||
},
|
||||
|
||||
async removeItem(key: string): Promise<void> {
|
||||
await redis.del(`${prefix}${key}`);
|
||||
},
|
||||
|
||||
async getAllItems(): Promise<T[]> {
|
||||
const keys = await redis.keys(`${prefix}*`);
|
||||
if (keys.length === 0) return [];
|
||||
|
||||
const values = await redis.mget(...keys);
|
||||
return values
|
||||
.filter(v => v !== null)
|
||||
.map(v => JSON.parse(v!));
|
||||
},
|
||||
|
||||
async* subscribe(): AsyncIterable<void> {
|
||||
const pubsub = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
|
||||
await pubsub.subscribe(`${namespace}:changes`);
|
||||
|
||||
for await (const message of pubsub) {
|
||||
if (message[0] === 'message' && message[1] === `${namespace}:changes`) {
|
||||
yield;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to notify subscribers of changes
|
||||
export async function notifyRedisChanges(namespace: string): Promise<void> {
|
||||
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379');
|
||||
await redis.publish(`${namespace}:changes`, 'update');
|
||||
await redis.quit();
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Update Session Storage
|
||||
|
||||
In `src/server/lib/auth.ts`:
|
||||
|
||||
```typescript
|
||||
import { createRedisKV } from './create-redis-kv.js';
|
||||
|
||||
// Replace this line:
|
||||
// export const sessionsKV = createKV<Session>("sessions");
|
||||
|
||||
// With this:
|
||||
export const sessionsKV = createRedisKV<Session>("sessions");
|
||||
```
|
||||
|
||||
### 4. Update Rate Limiter
|
||||
|
||||
In `src/server/lib/rate-limiter.ts`:
|
||||
|
||||
```typescript
|
||||
import Redis from 'ioredis';
|
||||
|
||||
const redis = new Redis(process.env.REDIS_URL || 'redis://localhost:6379', {
|
||||
retryDelayOnFailover: 100,
|
||||
enableReadyCheck: false,
|
||||
maxRetriesPerRequest: null,
|
||||
lazyConnect: true,
|
||||
});
|
||||
|
||||
export async function checkBookingRateLimit(
|
||||
email?: string,
|
||||
ip?: string
|
||||
): Promise<{ allowed: boolean; resetTime?: number }> {
|
||||
const now = Date.now();
|
||||
const windowMs = 15 * 60 * 1000; // 15 minutes
|
||||
const maxRequests = 3;
|
||||
|
||||
const results = await Promise.all([
|
||||
email ? checkRateLimit(`booking:email:${email}`, maxRequests, windowMs) : { allowed: true },
|
||||
ip ? checkRateLimit(`booking:ip:${ip}`, maxRequests, windowMs) : { allowed: true }
|
||||
]);
|
||||
|
||||
return {
|
||||
allowed: results.every(r => r.allowed),
|
||||
resetTime: results.find(r => !r.allowed)?.resetTime
|
||||
};
|
||||
}
|
||||
|
||||
async function checkRateLimit(
|
||||
key: string,
|
||||
maxRequests: number,
|
||||
windowMs: number
|
||||
): Promise<{ allowed: boolean; resetTime?: number }> {
|
||||
const now = Date.now();
|
||||
const windowStart = now - windowMs;
|
||||
|
||||
// Use Redis sorted set for sliding window
|
||||
const pipeline = redis.pipeline();
|
||||
|
||||
// Remove old entries
|
||||
pipeline.zremrangebyscore(key, 0, windowStart);
|
||||
|
||||
// Count current entries
|
||||
pipeline.zcard(key);
|
||||
|
||||
// Add current request
|
||||
pipeline.zadd(key, now, `${now}-${Math.random()}`);
|
||||
|
||||
// Set expiry
|
||||
pipeline.expire(key, Math.ceil(windowMs / 1000));
|
||||
|
||||
const results = await pipeline.exec();
|
||||
const currentCount = results?.[1]?.[1] as number || 0;
|
||||
|
||||
return {
|
||||
allowed: currentCount < maxRequests,
|
||||
resetTime: currentCount >= maxRequests ? windowStart + windowMs : undefined
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Environment Configuration
|
||||
|
||||
Update `.env.example`:
|
||||
|
||||
```bash
|
||||
# Redis Configuration (optional - required for multi-instance SaaS deployments)
|
||||
# For single-instance deployments, the default in-memory storage is sufficient
|
||||
# See docs/redis-migration.md for migration guide
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_TLS_ENABLED=false # Enable for production
|
||||
REDIS_PASSWORD=your_redis_password # Optional
|
||||
```
|
||||
|
||||
## Migration Strategy
|
||||
|
||||
### Development Testing
|
||||
|
||||
1. **Start Redis locally**:
|
||||
```bash
|
||||
docker run -d -p 6379:6379 redis:alpine
|
||||
```
|
||||
|
||||
2. **Test session functionality**:
|
||||
- Login/logout
|
||||
- Session validation
|
||||
- CSRF token generation/validation
|
||||
- Session rotation
|
||||
|
||||
3. **Test rate limiting**:
|
||||
- Verify rate limit enforcement
|
||||
- Check sliding window behavior
|
||||
|
||||
4. **Simulate multi-instance**:
|
||||
- Run multiple server instances
|
||||
- Verify session sharing works
|
||||
|
||||
### Staging Deployment
|
||||
|
||||
1. **Deploy Redis in staging**
|
||||
2. **Run single instance first** to verify functionality
|
||||
3. **Scale to multiple instances** and test session sharing
|
||||
4. **Monitor Redis memory usage** and connection pool
|
||||
|
||||
### Production Rollout
|
||||
|
||||
#### Option A: Blue/Green Deployment
|
||||
1. Deploy new version with Redis to green environment
|
||||
2. Test thoroughly with production-like data
|
||||
3. Switch traffic to green environment
|
||||
4. **Note**: Existing sessions will be lost (users need to re-login)
|
||||
|
||||
#### Option B: Gradual Migration
|
||||
1. Deploy Redis alongside existing system
|
||||
2. Write to both stores temporarily (dual-write)
|
||||
3. Read from Redis first, fallback to in-memory
|
||||
4. After verification period, remove in-memory store
|
||||
|
||||
## Monitoring & Maintenance
|
||||
|
||||
### Key Metrics
|
||||
|
||||
- **Redis memory usage**: Monitor `used_memory` and `used_memory_peak`
|
||||
- **Connection pool**: Track active connections and pool utilization
|
||||
- **Session operations**: Monitor session creation/validation latency
|
||||
- **Rate limit hits**: Track rate limit enforcement effectiveness
|
||||
|
||||
### Backup Strategy
|
||||
|
||||
- **Enable Redis persistence**: Configure RDB snapshots or AOF logging
|
||||
- **Regular backups**: Backup Redis data regularly
|
||||
- **Session data**: Ephemeral, but rate limit data should be backed up
|
||||
|
||||
### Scaling Considerations
|
||||
|
||||
- **Redis Cluster**: For horizontal scaling across multiple nodes
|
||||
- **Redis Sentinel**: For high availability with automatic failover
|
||||
- **Connection pooling**: Configure appropriate pool sizes
|
||||
|
||||
## Rollback Plan
|
||||
|
||||
If issues arise, you can revert to in-memory storage:
|
||||
|
||||
1. **Remove Redis imports** and revert to `createKV`
|
||||
2. **Remove Redis environment variables**
|
||||
3. **Redeploy** with in-memory storage
|
||||
4. **Note**: All sessions will be lost (users need to re-login)
|
||||
|
||||
## Cost Considerations
|
||||
|
||||
### Redis Hosting Costs
|
||||
|
||||
- **Self-hosted**: Server costs + maintenance
|
||||
- **Managed services**:
|
||||
- Redis Cloud: ~$7/month for 30MB
|
||||
- AWS ElastiCache: ~$15/month for t3.micro
|
||||
- Azure Cache: ~$16/month for Basic tier
|
||||
|
||||
### Memory Requirements
|
||||
|
||||
**Session storage**: Minimal
|
||||
- Example: 1000 concurrent users × 1KB per session = ~1MB
|
||||
- CSRF tokens add minimal overhead
|
||||
|
||||
**Rate limiting**: Negligible
|
||||
- Sliding window data is automatically cleaned up
|
||||
- Minimal memory footprint per IP/email
|
||||
|
||||
## Alternative Solutions
|
||||
|
||||
### Sticky Sessions
|
||||
- Load balancer routes user to same instance
|
||||
- **Pros**: Simpler implementation
|
||||
- **Cons**: Less resilient, harder to scale
|
||||
|
||||
### Database-backed Sessions
|
||||
- Use PostgreSQL/MySQL instead of Redis
|
||||
- **Pros**: No additional infrastructure
|
||||
- **Cons**: Higher latency, more database load
|
||||
|
||||
### JWT Tokens
|
||||
- Stateless authentication tokens
|
||||
- **Pros**: No server-side session storage needed
|
||||
- **Cons**: No server-side session invalidation, different security model
|
||||
|
||||
## References
|
||||
|
||||
- [Session Management Guide](session-management.md)
|
||||
- [Rate Limiting Documentation](rate-limiting.md)
|
||||
- [Redis Documentation](https://redis.io/documentation)
|
||||
- [ioredis Library](https://github.com/luin/ioredis)
|
||||
- [Redis Best Practices](https://redis.io/docs/manual/admin/)
|
251
docs/session-management.md
Normal file
251
docs/session-management.md
Normal file
@@ -0,0 +1,251 @@
|
||||
# Session Management & CSRF Protection
|
||||
|
||||
## Overview
|
||||
|
||||
This application uses **HttpOnly cookie-based session management** with CSRF protection to provide secure authentication while protecting against common web vulnerabilities like XSS and CSRF attacks.
|
||||
|
||||
### Security Benefits
|
||||
|
||||
- **XSS Protection**: SessionId stored in HttpOnly cookies is not accessible to malicious JavaScript
|
||||
- **CSRF Protection**: Double-submit cookie pattern prevents cross-site request forgery
|
||||
- **Session Rotation**: New sessions created after login and password changes prevent session fixation
|
||||
- **GDPR Compliance**: HttpOnly cookies provide better privacy protection than localStorage
|
||||
|
||||
## Architecture
|
||||
|
||||
### Session Storage
|
||||
Sessions are stored in an in-memory KV store with the following structure:
|
||||
```typescript
|
||||
type Session = {
|
||||
id: string;
|
||||
userId: string;
|
||||
expiresAt: string;
|
||||
createdAt: string;
|
||||
csrfToken?: string;
|
||||
}
|
||||
```
|
||||
|
||||
- **Expiration**: 24 hours
|
||||
- **Storage**: In-memory KV store (single-instance deployment)
|
||||
- **CSRF Token**: Cryptographically secure 64-character hex string
|
||||
|
||||
### Cookie Configuration
|
||||
|
||||
#### Session Cookie (`sessionId`)
|
||||
- **Type**: HttpOnly, Secure (production), SameSite=Lax
|
||||
- **Path**: `/`
|
||||
- **MaxAge**: 86400 seconds (24 hours)
|
||||
- **Purpose**: Authenticates user across requests
|
||||
|
||||
#### CSRF Cookie (`csrf-token`)
|
||||
- **Type**: Non-HttpOnly, Secure (production), SameSite=Lax
|
||||
- **Path**: `/`
|
||||
- **MaxAge**: 86400 seconds (24 hours)
|
||||
- **Purpose**: Provides CSRF token for JavaScript to include in requests
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
The application uses the **double-submit cookie pattern**:
|
||||
|
||||
1. **Server-side**: CSRF token generated and stored in session
|
||||
2. **Client-side**: Same token stored in non-HttpOnly cookie
|
||||
3. **Validation**: Token from `X-CSRF-Token` header must match session token
|
||||
4. **Timing-safe comparison**: Prevents timing attacks
|
||||
|
||||
### Session Rotation
|
||||
|
||||
Sessions are automatically rotated (new session created, old invalidated) after:
|
||||
- Successful login
|
||||
- Password changes
|
||||
|
||||
This prevents session fixation attacks.
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Server-side
|
||||
|
||||
#### Cookie Parsing Middleware (`src/server/routes/rpc.ts`)
|
||||
```typescript
|
||||
// Cookie parsing middleware - extracts sessionId from cookies
|
||||
rpcApp.use("/*", async (c, next) => {
|
||||
try {
|
||||
const sessionId = getCookie(c, SESSION_COOKIE_NAME);
|
||||
c.set('sessionId', sessionId || null);
|
||||
await next();
|
||||
} catch (error) {
|
||||
console.error("Cookie parsing error:", error);
|
||||
c.set('sessionId', null);
|
||||
await next();
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### Authentication Helper (`src/server/lib/auth.ts`)
|
||||
Key functions:
|
||||
- `generateCSRFToken()`: Creates cryptographically secure token
|
||||
- `getSessionFromCookies(c)`: Extracts and validates session from cookies
|
||||
- `validateCSRFToken(c, sessionId)`: Validates CSRF token from header
|
||||
- `assertOwner(c)`: Validates owner role with session and CSRF checks
|
||||
- `rotateSession(oldSessionId, userId)`: Creates new session, invalidates old
|
||||
|
||||
#### RPC Handler Updates
|
||||
All admin-only RPC handlers now:
|
||||
- Accept Hono `context` parameter
|
||||
- Use `assertOwner(context)` for authentication
|
||||
- Remove `sessionId` from input schemas
|
||||
- Automatically get session from cookies
|
||||
|
||||
### Client-side
|
||||
|
||||
#### RPC Client Configuration (`src/client/rpc-client.ts`)
|
||||
```typescript
|
||||
const link = new RPCLink({
|
||||
url: `${window.location.origin}/rpc`,
|
||||
headers: () => {
|
||||
const csrfToken = getCSRFToken();
|
||||
return csrfToken ? { 'X-CSRF-Token': csrfToken } : {};
|
||||
},
|
||||
fetch: (request, init) => {
|
||||
return fetch(request, {
|
||||
...init,
|
||||
credentials: 'include' // Include cookies with all requests
|
||||
});
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
#### AuthProvider Updates (`src/client/components/auth-provider.tsx`)
|
||||
- Removed all localStorage usage
|
||||
- Sessions managed entirely server-side
|
||||
- No client-side sessionId storage
|
||||
|
||||
## Security Features
|
||||
|
||||
### XSS Protection
|
||||
- SessionId not accessible to JavaScript (HttpOnly cookie)
|
||||
- Malicious scripts cannot steal session tokens
|
||||
|
||||
### CSRF Protection
|
||||
- Token validation on all state-changing operations (non-GET requests)
|
||||
- Double-submit cookie pattern prevents CSRF attacks
|
||||
- Timing-safe comparison prevents timing attacks
|
||||
|
||||
### Session Fixation Prevention
|
||||
- Session rotation after authentication events
|
||||
- Old sessions invalidated when new ones created
|
||||
|
||||
### Secure Defaults
|
||||
- Secure flag enabled in production (requires HTTPS)
|
||||
- SameSite=Lax prevents most CSRF attacks
|
||||
- HttpOnly cookies prevent XSS token theft
|
||||
|
||||
## Development vs Production
|
||||
|
||||
### Development
|
||||
- `secure: false` - allows cookies over HTTP (localhost)
|
||||
- `NODE_ENV !== 'production'` detection
|
||||
|
||||
### Production
|
||||
- `secure: true` - requires HTTPS
|
||||
- All security flags enabled
|
||||
|
||||
## API Reference
|
||||
|
||||
### Key Functions (`src/server/lib/auth.ts`)
|
||||
|
||||
#### `generateCSRFToken(): string`
|
||||
Creates a cryptographically secure random token using `crypto.randomBytes(32).toString('hex')`.
|
||||
|
||||
#### `getSessionFromCookies(c: Context): Promise<Session | null>`
|
||||
Extracts sessionId from cookies, validates session exists and hasn't expired.
|
||||
|
||||
#### `validateCSRFToken(c: Context, sessionId: string): Promise<void>`
|
||||
Validates CSRF token from `X-CSRF-Token` header against session token using timing-safe comparison.
|
||||
|
||||
#### `assertOwner(c: Context): Promise<void>`
|
||||
Validates user has owner role and session is valid. Automatically validates CSRF token for non-GET requests.
|
||||
|
||||
#### `rotateSession(oldSessionId: string, userId: string): Promise<Session>`
|
||||
Creates new session with new ID and CSRF token, deletes old session.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### Existing Password Hashes
|
||||
- Base64 password hashes automatically migrated to bcrypt on server startup
|
||||
- No manual intervention required
|
||||
|
||||
### Session Invalidation
|
||||
- Old localStorage sessions will be invalidated
|
||||
- Users need to re-login once after deployment
|
||||
|
||||
### Testing Migration
|
||||
1. Deploy new version
|
||||
2. Verify login creates cookies (check browser DevTools)
|
||||
3. Test CSRF protection by manually calling API without token
|
||||
4. Verify session rotation after password change
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Cookies Not Being Sent
|
||||
- Check `credentials: 'include'` in fetch configuration
|
||||
- Verify CORS settings allow credentials
|
||||
- Check cookie domain/path configuration
|
||||
|
||||
### CSRF Validation Failing
|
||||
- Ensure `X-CSRF-Token` header is set
|
||||
- Verify CSRF cookie is accessible to JavaScript
|
||||
- Check token format (64-character hex string)
|
||||
|
||||
### Session Expired Errors
|
||||
- Check cookie expiration settings
|
||||
- Verify server time synchronization
|
||||
- Check session cleanup logic
|
||||
|
||||
### Cross-Origin Issues
|
||||
- Review CORS configuration for credentials
|
||||
- Ensure domain configuration matches deployment
|
||||
- Check SameSite cookie settings
|
||||
|
||||
## Future Scaling
|
||||
|
||||
For multi-instance deployments, see [Redis Migration Guide](redis-migration.md) for migrating to centralized session storage.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
### Required Environment Variables
|
||||
|
||||
```bash
|
||||
# Domain Configuration
|
||||
DOMAIN=localhost:5173 # For production: your-domain.com
|
||||
# Note: Session cookies are scoped to this domain
|
||||
|
||||
# Server Configuration
|
||||
NODE_ENV=development # Set to 'production' for production deployment
|
||||
PORT=3000
|
||||
```
|
||||
|
||||
### Optional Environment Variables
|
||||
|
||||
```bash
|
||||
# Redis Configuration (for multi-instance deployments)
|
||||
# See docs/redis-migration.md for migration guide
|
||||
REDIS_URL=redis://localhost:6379
|
||||
REDIS_TLS_ENABLED=false # Set to true for production
|
||||
REDIS_PASSWORD=your_redis_password # Optional
|
||||
```
|
||||
|
||||
### Cookie Behavior by Environment
|
||||
|
||||
- **Development** (`NODE_ENV !== 'production'`):
|
||||
- `secure: false` - cookies work over HTTP
|
||||
- `sameSite: 'Lax'` - allows cross-site navigation
|
||||
|
||||
- **Production** (`NODE_ENV === 'production'`):
|
||||
- `secure: true` - requires HTTPS
|
||||
- `sameSite: 'Lax'` - prevents most CSRF attacks
|
||||
|
||||
## References
|
||||
|
||||
- [OWASP CSRF Prevention](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html)
|
||||
- [MDN HttpOnly Cookies](https://developer.mozilla.org/en-US/docs/Web/HTTP/Cookies#restrict_access_to_cookies)
|
||||
- [RFC 6265 - HTTP State Management](https://tools.ietf.org/html/rfc6265)
|
Reference in New Issue
Block a user