- DB Migration: api_keys bekommt 'permissions' + 'customer_id' Spalten - Permissions: agent | read | write | admin - agent: nur /api/v1/agent/ws + /api/v1/agent/heartbeat - read: GET-Endpoints (optional customer-scoped) - write: alles ausser Terminal - admin: voll (Terminal bleibt JWT-only) - Bestehende Keys werden auf 'admin' migriert (Abwaertskompatibilitaet) - Frontend: API-Key Verwaltung zeigt Permissions-Badge + Kunden-Scope - API-Client: createAPIKey nimmt jetzt permissions + customer_id
264 lines
7.2 KiB
Go
264 lines
7.2 KiB
Go
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))
|
|
})
|
|
}
|