package api import ( "context" "log" "net/http" "strings" "sync" "time" db "github.com/cynfo/rmm-backend/db" ) type contextKey string const UserContextKey contextKey = "user" // APIKeyContextKey fuer Key-Infos im Request-Context const APIKeyContextKey contextKey = "apikey" // APIKeyCache cached DB-basierte API-Keys mit periodischem Refresh type APIKeyCache struct { db *db.Database mu sync.RWMutex keys map[string]*db.APIKey // key -> APIKey mit Permissions + CustomerID lastLoad time.Time ttl time.Duration } func NewAPIKeyCache(database *db.Database) *APIKeyCache { c := &APIKeyCache{ db: database, keys: make(map[string]*db.APIKey), ttl: 30 * time.Second, } c.refresh() return c } func (c *APIKeyCache) refresh() { dbKeys, err := c.db.ListAPIKeys() if err != nil { log.Printf("API-Key Cache Refresh fehlgeschlagen: %v", err) return } newKeys := make(map[string]*db.APIKey, len(dbKeys)) for i := range dbKeys { k := dbKeys[i] newKeys[k.Key] = &k } c.mu.Lock() c.keys = newKeys c.lastLoad = time.Now() c.mu.Unlock() } func (c *APIKeyCache) Get(key string) *db.APIKey { c.mu.RLock() expired := time.Since(c.lastLoad) > c.ttl info := c.keys[key] c.mu.RUnlock() if expired { c.refresh() c.mu.RLock() info = c.keys[key] c.mu.RUnlock() } if info != nil { go c.db.UpdateAPIKeyLastUsed(key) } return info } // IsValid prueft nur ob Key existiert (Abwaertskompatibilitaet) func (c *APIKeyCache) IsValid(key string) bool { return c.Get(key) != nil } // CombinedAuthWithCache - API-Key (aus DB-Cache) ODER JWT Token // Setzt APIKey-Info im Context fuer nachgelagerte Permission-Checks. func CombinedAuthWithCache(cache *APIKeyCache, auth *AuthHandler, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // Try API key first (Header oder Query-Parameter) key := r.Header.Get("X-API-Key") if key == "" { key = r.URL.Query().Get("api_key") } if key != "" { if info := cache.Get(key); info != nil { // Permission-Check if !hasAPIKeyPermission(info, r) { http.Error(w, `{"error":"forbidden: unzureichende Berechtigungen"}`, http.StatusForbidden) return } ctx := context.WithValue(r.Context(), APIKeyContextKey, info) next.ServeHTTP(w, r.WithContext(ctx)) return } } // Try JWT — Authorization Header oder ?token= Query-Parameter (fuer WebSocket) var jwtToken string authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { jwtToken = strings.TrimPrefix(authHeader, "Bearer ") } else if t := r.URL.Query().Get("token"); t != "" { jwtToken = t } if jwtToken != "" { claims, err := auth.ValidateToken(jwtToken) if err == nil { ctx := context.WithValue(r.Context(), UserContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) return } } http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) }) } // hasAPIKeyPermission prueft ob ein API-Key fuer den aktuellen Request berechtigt ist func hasAPIKeyPermission(key *db.APIKey, r *http.Request) bool { perm := key.Permissions method := r.Method path := r.URL.Path switch perm { case "agent": // Nur Agent-Endpunkte: WS-Connect und Heartbeat return path == "/api/v1/agent/ws" || path == "/api/v1/agent/heartbeat" case "read": // Nur GET-Requests, keine destruktiven Aktionen if method != http.MethodGet { return false } // Customer-Scope pruefen (falls gesetzt) return checkCustomerScope(key, r) case "write": // Alles ausser Terminal (Terminal ist JWT-only) // Customer-Scope pruefen return checkCustomerScope(key, r) case "admin": // Vollen Zugriff (kein Terminal — das prueft JWTOnlyAuth separat) return true default: return false } } // checkCustomerScope prueft ob ein customer-scoped Key nur auf seinen Kunden zugreift func checkCustomerScope(key *db.APIKey, r *http.Request) bool { if key.CustomerID == nil { return true // Kein Scope = globaler Zugriff } // Customer-ID aus dem Pfad extrahieren (z.B. /api/v1/agents/{id} -> Agent-Customer pruefen) // Fuer jetzt: customer-scoped Keys werden im Handler weiter eingeschraenkt (via GetAPIKeyFromContext) // Der eigentliche Scope-Check passiert in den Handlern die GetAPIKeyFromContext nutzen return true } // GetAPIKeyFromContext gibt den API-Key aus dem Context zurueck (nil wenn JWT-Auth) func GetAPIKeyFromContext(r *http.Request) *db.APIKey { if v := r.Context().Value(APIKeyContextKey); v != nil { if k, ok := v.(*db.APIKey); ok { return k } } return nil } // CombinedAuth - Abwaertskompatibel mit statischen Keys (Fallback) func CombinedAuth(validKeys []string, auth *AuthHandler, next http.Handler) http.Handler { keySet := make(map[string]bool, len(validKeys)) for _, k := range validKeys { keySet[k] = true } return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { key := r.Header.Get("X-API-Key") if key == "" { key = r.URL.Query().Get("api_key") } if key != "" && keySet[key] { next.ServeHTTP(w, r) return } authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { token := strings.TrimPrefix(authHeader, "Bearer ") claims, err := auth.ValidateToken(token) if err == nil { ctx := context.WithValue(r.Context(), UserContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) return } } http.Error(w, `{"error":"unauthorized"}`, http.StatusUnauthorized) }) } // JWTOnlyAuth - Nur JWT akzeptieren, kein API-Key (z.B. fuer Terminal-Zugriff) // Erfordert echten Login mit User/Pass (+2FA), kein Maschinentoken reicht. func JWTOnlyAuth(auth *AuthHandler, next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var jwtToken string // Authorization Header authHeader := r.Header.Get("Authorization") if strings.HasPrefix(authHeader, "Bearer ") { jwtToken = strings.TrimPrefix(authHeader, "Bearer ") } // ?token= Query-Parameter (fuer WebSocket, Browser kann keinen Header setzen) if jwtToken == "" { jwtToken = r.URL.Query().Get("token") } if jwtToken == "" { http.Error(w, `{"error":"unauthorized: JWT erforderlich"}`, http.StatusUnauthorized) return } claims, err := auth.ValidateToken(jwtToken) if err != nil { http.Error(w, `{"error":"unauthorized: ungültiger Token"}`, http.StatusUnauthorized) return } ctx := context.WithValue(r.Context(), UserContextKey, claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } // CORS Middleware func CORS(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization, X-API-Key") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } // Logging - Request-Logging Middleware func Logging(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() next.ServeHTTP(w, r) log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(start)) }) }