Phase 2 Week 6: Device Authentication & Rate Limiting
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
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user