From 1a769783dcbd6ff1e874b519a1178b1395319c6b Mon Sep 17 00:00:00 2001 From: John O'Keefe Date: Fri, 30 Jan 2026 16:47:29 -0500 Subject: [PATCH] Phase 2 Week 6: Device Authentication & Rate Limiting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement per-device authentication with rate limiting and permissions. Device Rate Limiter (device_rate_limiter.go): - DeviceRateLimiter: Track requests per device and request type - CheckRateLimit: Verify device hasn't exceeded limits - GetRemainingRequests: Return remaining request quota - Reset: Clear rate limit data for specific device - cleanupOldEntries: Remove stale entries automatically - Request Types: sync, progress, metadata - Rate Limits: * Sync requests: 60/minute * Progress updates: 120/minute (page turns) * Metadata requests: 30/minute Device Auth Middleware Updates: - Add rateLimiter to DeviceAuthMiddleware - Check rate limits during authentication - Return 429 Too Many Requests when limits exceeded - Set rate limit headers: * X-RateLimit-Limit: Request limit * X-RateLimit-Remaining: Quota remaining * X-RateLimit-Reset: Reset time - getRequestType: Determine request type from URL path Request Type Detection: - /progress endpoints → progress type (120/min) - /metadata, /library endpoints → metadata type (30/min) - All other sync endpoints → sync type (60/min) Benefits: - Prevent device abuse and DoS attacks - Fair resource allocation across devices - Higher limits for frequent operations (page turns) - Lower limits for expensive operations (metadata) - Automatic cleanup of stale data - Per-device isolation (one device can't affect others) Integration with Device Auth: - Rate limit check happens after token validation - Before processing actual sync request - Returns standard HTTP 429 with retry info - Works seamlessly with existing device middleware Device revocation still available via: - DELETE /api/devices/:id endpoint - Sets auth_token to NULL - Disables sync_enabled flag --- internal/middleware/device_auth.go | 42 ++++- internal/middleware/device_rate_limiter.go | 177 +++++++++++++++++++++ 2 files changed, 217 insertions(+), 2 deletions(-) create mode 100644 internal/middleware/device_rate_limiter.go diff --git a/internal/middleware/device_auth.go b/internal/middleware/device_auth.go index 899242a..ef72b9c 100644 --- a/internal/middleware/device_auth.go +++ b/internal/middleware/device_auth.go @@ -21,12 +21,14 @@ type DeviceContext struct { } type DeviceAuthMiddleware struct { - db *database.Queries + db *database.Queries + rateLimiter *DeviceRateLimiter } func NewDeviceAuthMiddleware(db *database.Queries) *DeviceAuthMiddleware { return &DeviceAuthMiddleware{ - db: db, + db: db, + rateLimiter: NewDeviceRateLimiter(), } } @@ -60,6 +62,32 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF }) } + requestType := m.getRequestType(c.Request().URL.Path) + deviceUUID := uuid.UUID(device.ID.Bytes) + deviceID := deviceUUID.String() + + config := DeviceRateLimitConfig{ + SyncRequestsPerMinute: 60, + ProgressUpdatesPerMinute: 120, + MetadataRequestsPerMinute: 30, + } + + if !m.rateLimiter.CheckRateLimit(deviceID, requestType, config) { + remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) + c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Remaining", string(rune(remaining))) + c.Response().Header().Set("X-RateLimit-Reset", "60") + return c.JSON(http.StatusTooManyRequests, map[string]string{ + "error": "rate limit exceeded", + "message": "Too many requests", + "remaining": string(rune(remaining)), + }) + } + + remaining := m.rateLimiter.GetRemainingRequests(deviceID, requestType, config) + c.Response().Header().Set("X-RateLimit-Limit", "60") + c.Response().Header().Set("X-RateLimit-Remaining", string(rune(remaining))) + ctx := DeviceContext{ ID: device.ID.Bytes, UserID: device.UserID.Bytes, @@ -78,6 +106,16 @@ func (m *DeviceAuthMiddleware) Authenticate(next echo.HandlerFunc) echo.HandlerF } } +func (m *DeviceAuthMiddleware) getRequestType(path string) string { + if strings.Contains(path, "/progress") { + return "progress" + } + if strings.Contains(path, "/metadata") || strings.Contains(path, "/library") { + return "metadata" + } + return "sync" +} + func (m *DeviceAuthMiddleware) RequirePermission(permission string) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { diff --git a/internal/middleware/device_rate_limiter.go b/internal/middleware/device_rate_limiter.go new file mode 100644 index 0000000..88caf6f --- /dev/null +++ b/internal/middleware/device_rate_limiter.go @@ -0,0 +1,177 @@ +package middleware + +import ( + "sync" + "time" +) + +type DeviceRateLimiter struct { + requests map[string]*DeviceRequestInfo + mu sync.RWMutex + cleanup *time.Ticker +} + +type DeviceRequestInfo struct { + Requests []time.Time + LastSeen time.Time + DeviceID string +} + +type DeviceRateLimitConfig struct { + SyncRequestsPerMinute int + ProgressUpdatesPerMinute int + MetadataRequestsPerMinute int +} + +const ( + DefaultSyncRequestsPerMinute = 60 + DefaultProgressUpdatesPerMinute = 120 + DefaultMetadataRequestsPerMinute = 30 + CleanupInterval = 5 * time.Minute + RequestWindow = 1 * time.Minute +) + +func NewDeviceRateLimiter() *DeviceRateLimiter { + limiter := &DeviceRateLimiter{ + requests: make(map[string]*DeviceRequestInfo), + cleanup: time.NewTicker(CleanupInterval), + } + + go limiter.cleanupOldEntries() + + return limiter +} + +func (drl *DeviceRateLimiter) CheckRateLimit(deviceID string, requestType string, config DeviceRateLimitConfig) bool { + drl.mu.Lock() + defer drl.mu.Unlock() + + now := time.Now() + key := deviceID + ":" + requestType + + info, exists := drl.requests[key] + if !exists { + info = &DeviceRequestInfo{ + Requests: []time.Time{}, + DeviceID: deviceID, + LastSeen: now, + } + drl.requests[key] = info + } + + var limit int + switch requestType { + case "sync": + limit = config.SyncRequestsPerMinute + case "progress": + limit = config.ProgressUpdatesPerMinute + case "metadata": + limit = config.MetadataRequestsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } + + if limit == 0 { + limit = DefaultSyncRequestsPerMinute + } + + info.Requests = append(info.Requests, now) + info.LastSeen = now + + cutoff := now.Add(-RequestWindow) + validRequests := 0 + for _, reqTime := range info.Requests { + if reqTime.After(cutoff) { + validRequests++ + } + } + info.Requests = info.Requests[len(info.Requests)-validRequests:] + + return validRequests <= limit +} + +func (drl *DeviceRateLimiter) GetRemainingRequests(deviceID string, requestType string, config DeviceRateLimitConfig) int { + drl.mu.RLock() + defer drl.mu.RUnlock() + + key := deviceID + ":" + requestType + info, exists := drl.requests[key] + if !exists { + var limit int + switch requestType { + case "sync": + limit = config.SyncRequestsPerMinute + case "progress": + limit = config.ProgressUpdatesPerMinute + case "metadata": + limit = config.MetadataRequestsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } + if limit == 0 { + limit = DefaultSyncRequestsPerMinute + } + return limit + } + + now := time.Now() + cutoff := now.Add(-RequestWindow) + validRequests := 0 + for _, reqTime := range info.Requests { + if reqTime.After(cutoff) { + validRequests++ + } + } + + var limit int + switch requestType { + case "sync": + limit = config.SyncRequestsPerMinute + case "progress": + limit = config.ProgressUpdatesPerMinute + case "metadata": + limit = config.MetadataRequestsPerMinute + default: + limit = DefaultSyncRequestsPerMinute + } + if limit == 0 { + limit = DefaultSyncRequestsPerMinute + } + + remaining := limit - validRequests + if remaining < 0 { + remaining = 0 + } + + return remaining +} + +func (drl *DeviceRateLimiter) Reset(deviceID string) { + drl.mu.Lock() + defer drl.mu.Unlock() + + for key := range drl.requests { + if len(key) > len(deviceID) && key[:len(deviceID)] == deviceID { + delete(drl.requests, key) + } + } +} + +func (drl *DeviceRateLimiter) cleanupOldEntries() { + for range drl.cleanup.C { + drl.mu.Lock() + cutoff := time.Now().Add(-10 * time.Minute) + + for key, info := range drl.requests { + if info.LastSeen.Before(cutoff) { + delete(drl.requests, key) + } + } + + drl.mu.Unlock() + } +} + +func (drl *DeviceRateLimiter) Stop() { + drl.cleanup.Stop() +}