diff --git a/cmd/server/main.go b/cmd/server/main.go index 1a303d6..768f40e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -310,7 +310,7 @@ func main() { // API Routes // Internal Routes internalGroup := r.Group("/internal") - internalGroup.Use(middleware.InternalAuthMiddleware(cfg.Internal.StatsToken)) + internalGroup.Use(middleware.InternalAuthMiddleware(cfg.Internal.StatsToken, cfg.Internal.AllowAnonymous)) { internalGroup.POST("/stats/flush", internalHandler.FlushStats) internalGroup.POST("/apikey-stats/flush", internalHandler.FlushAPIKeyStats) diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go index 8a6fa9c..6d92762 100644 --- a/internal/api/auth_handler.go +++ b/internal/api/auth_handler.go @@ -2,7 +2,9 @@ package api import ( "net/http" + "strconv" "strings" + "time" "github.com/ez-api/ez-api/internal/model" "github.com/ez-api/ez-api/internal/service" @@ -64,6 +66,8 @@ type WhoamiKeyResponse struct { Status string `json:"status" example:"active"` // Status: active/suspended IssuedAtEpoch int64 `json:"issued_at_epoch" example:"1"` // Master epoch when issued IssuedBy string `json:"issued_by" example:"master"` // "master" or "admin" + AllowIPs string `json:"allow_ips,omitempty" example:""` // IP whitelist (for diagnostics) + DenyIPs string `json:"deny_ips,omitempty" example:""` // IP blacklist (for diagnostics) CreatedAt int64 `json:"created_at" example:"1703505600"` // Unix timestamp UpdatedAt int64 `json:"updated_at" example:"1703505600"` // Unix timestamp } @@ -95,6 +99,9 @@ type WhoamiResponse struct { Scopes string `json:"scopes,omitempty" example:"chat:write"` IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"` IssuedBy string `json:"issued_by,omitempty" example:"master"` + AllowIPs string `json:"allow_ips,omitempty" example:""` // IP whitelist (for diagnostics) + DenyIPs string `json:"deny_ips,omitempty" example:""` // IP blacklist (for diagnostics) + ExpiresAt int64 `json:"expires_at,omitempty" example:"0"` // Expiration timestamp (0 = never) } // Whoami godoc @@ -157,45 +164,72 @@ func (h *AuthHandler) Whoami(c *gin.Context) { return } - // 3. Try child key (API key) - lookup by token hash + // 3. Try child key (API key) - validate using Redis first (consistent with balancer) tokenHash := tokenhash.HashToken(token) - // First try Redis cache + // Validate token using Redis (same logic as balancer) if h.rdb != nil { - keyData, err := h.rdb.HGetAll(c.Request.Context(), "auth:token:"+tokenHash).Result() - if err == nil && len(keyData) > 0 && keyData["status"] == "active" { - // Found in Redis, now get full data from DB - var key model.Key - if err := h.db.Where("token_hash = ?", tokenHash).First(&key).Error; err == nil { - if key.Status == "active" { - c.JSON(http.StatusOK, WhoamiResponse{ - Type: "key", - ID: key.ID, - MasterID: key.MasterID, - Group: key.Group, - Scopes: strings.TrimSpace(key.Scopes), - DefaultNamespace: strings.TrimSpace(key.DefaultNamespace), - Namespaces: strings.TrimSpace(key.Namespaces), - Status: key.Status, - IssuedAtEpoch: key.IssuedAtEpoch, - IssuedBy: strings.TrimSpace(key.IssuedBy), - CreatedAt: key.CreatedAt.UTC().Unix(), - UpdatedAt: key.UpdatedAt.UTC().Unix(), - }) - return - } - } - } - } + ctx := c.Request.Context() - // Fallback: direct database lookup - var key model.Key - if err := h.db.Where("token_hash = ?", tokenHash).First(&key).Error; err == nil { - if key.Status == "active" { + // Get token metadata from Redis + keyData, err := h.rdb.HGetAll(ctx, "auth:token:"+tokenHash).Result() + if err != nil || len(keyData) == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) + return + } + + // Check token status + tokenStatus := strings.ToLower(keyData["status"]) + if tokenStatus != "" && tokenStatus != "active" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token is not active"}) + return + } + + // Get master ID and issued_at_epoch + masterIDStr := keyData["master_id"] + masterID, err := strconv.ParseUint(masterIDStr, 10, 64) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token metadata"}) + return + } + + issuedAtEpoch, _ := strconv.ParseInt(keyData["issued_at_epoch"], 10, 64) + + // Get master metadata from Redis + masterData, err := h.rdb.HGetAll(ctx, "auth:master:"+masterIDStr).Result() + if err != nil || len(masterData) == 0 { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master not found"}) + return + } + + // Check master status + masterStatus := strings.ToLower(masterData["status"]) + if masterStatus != "" && masterStatus != "active" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "master is not active"}) + return + } + + // Check epoch (key revocation) + masterEpoch, _ := strconv.ParseInt(masterData["epoch"], 10, 64) + if issuedAtEpoch < masterEpoch { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"}) + return + } + + // Check expiration + expiresAt, _ := strconv.ParseInt(keyData["expires_at"], 10, 64) + if expiresAt > 0 && time.Now().Unix() >= expiresAt { + c.JSON(http.StatusUnauthorized, gin.H{"error": "token has expired"}) + return + } + + // Token is valid, now get full data from DB for response + var key model.Key + if err := h.db.Where("token_hash = ?", tokenHash).First(&key).Error; err == nil { c.JSON(http.StatusOK, WhoamiResponse{ Type: "key", ID: key.ID, - MasterID: key.MasterID, + MasterID: uint(masterID), Group: key.Group, Scopes: strings.TrimSpace(key.Scopes), DefaultNamespace: strings.TrimSpace(key.DefaultNamespace), @@ -203,6 +237,9 @@ func (h *AuthHandler) Whoami(c *gin.Context) { Status: key.Status, IssuedAtEpoch: key.IssuedAtEpoch, IssuedBy: strings.TrimSpace(key.IssuedBy), + AllowIPs: strings.TrimSpace(key.AllowIPs), + DenyIPs: strings.TrimSpace(key.DenyIPs), + ExpiresAt: expiresAt, CreatedAt: key.CreatedAt.UTC().Unix(), UpdatedAt: key.UpdatedAt.UTC().Unix(), }) diff --git a/internal/config/config.go b/internal/config/config.go index b8e9548..2ba3e94 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -71,7 +71,8 @@ type QuotaConfig struct { } type InternalConfig struct { - StatsToken string + StatsToken string + AllowAnonymous bool } type SyncOutboxConfig struct { @@ -115,6 +116,7 @@ func Load() (*Config, error) { v.SetDefault("model_registry.timeout_seconds", 30) v.SetDefault("quota.reset_interval_seconds", 300) v.SetDefault("internal.stats_token", "") + v.SetDefault("internal.allow_anonymous", false) v.SetDefault("sync_outbox.enabled", true) v.SetDefault("sync_outbox.interval_seconds", 5) v.SetDefault("sync_outbox.batch_size", 200) @@ -151,6 +153,7 @@ func Load() (*Config, error) { _ = v.BindEnv("model_registry.timeout_seconds", "EZ_MODEL_REGISTRY_TIMEOUT_SECONDS") _ = v.BindEnv("quota.reset_interval_seconds", "EZ_QUOTA_RESET_INTERVAL_SECONDS") _ = v.BindEnv("internal.stats_token", "EZ_INTERNAL_STATS_TOKEN") + _ = v.BindEnv("internal.allow_anonymous", "EZ_INTERNAL_ALLOW_ANON") _ = v.BindEnv("sync_outbox.enabled", "EZ_SYNC_OUTBOX_ENABLED") _ = v.BindEnv("sync_outbox.interval_seconds", "EZ_SYNC_OUTBOX_INTERVAL_SECONDS") _ = v.BindEnv("sync_outbox.batch_size", "EZ_SYNC_OUTBOX_BATCH_SIZE") @@ -216,7 +219,8 @@ func Load() (*Config, error) { ResetIntervalSeconds: v.GetInt("quota.reset_interval_seconds"), }, Internal: InternalConfig{ - StatsToken: v.GetString("internal.stats_token"), + StatsToken: v.GetString("internal.stats_token"), + AllowAnonymous: v.GetBool("internal.allow_anonymous"), }, SyncOutbox: SyncOutboxConfig{ Enabled: v.GetBool("sync_outbox.enabled"), diff --git a/internal/middleware/internal_auth.go b/internal/middleware/internal_auth.go index 554e0cd..c57b17e 100644 --- a/internal/middleware/internal_auth.go +++ b/internal/middleware/internal_auth.go @@ -1,20 +1,35 @@ package middleware import ( + "log/slog" "net/http" "strings" "github.com/gin-gonic/gin" ) -func InternalAuthMiddleware(expectedToken string) gin.HandlerFunc { +// InternalAuthMiddleware protects internal endpoints. +// - If allowAnonymous is true, bypass all token checks and log INFO. +// - If allowAnonymous is false and expectedToken is empty, reject all requests. +// - Otherwise, require X-Internal-Token header to match expectedToken. +func InternalAuthMiddleware(expectedToken string, allowAnonymous bool) gin.HandlerFunc { expectedToken = strings.TrimSpace(expectedToken) return func(c *gin.Context) { - if expectedToken == "" { + if allowAnonymous { + slog.Info("internal endpoint accessed anonymously", + "path", c.Request.URL.Path, + "remote_addr", c.ClientIP()) c.Next() return } + // If token is empty and anonymous is not allowed, reject + if expectedToken == "" { + c.JSON(http.StatusUnauthorized, gin.H{"error": "internal authentication required but not configured"}) + c.Abort() + return + } + token := strings.TrimSpace(c.GetHeader("X-Internal-Token")) if token == "" || token != expectedToken { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid internal token"}) diff --git a/internal/service/master.go b/internal/service/master.go index 17b7226..a921db6 100644 --- a/internal/service/master.go +++ b/internal/service/master.go @@ -68,25 +68,6 @@ func (s *MasterService) ValidateMasterKey(masterKey string) (*model.Master, erro var master model.Master if err := s.db.Where("master_key_digest = ?", digest).First(&master).Error; err != nil { - if !errors.Is(err, gorm.ErrRecordNotFound) { - return nil, err - } - - // Backward compatibility: look for legacy rows without digest. - var masters []model.Master - if err := s.db.Where("master_key_digest = '' OR master_key_digest IS NULL").Find(&masters).Error; err != nil { - return nil, err - } - for _, m := range masters { - if bcrypt.CompareHashAndPassword([]byte(m.MasterKey), []byte(masterKey)) == nil { - master = m - // Opportunistically backfill digest for next time. - if strings.TrimSpace(m.MasterKeyDigest) == "" { - _ = s.db.Model(&m).Update("master_key_digest", digest).Error - } - goto verified - } - } return nil, errors.New("invalid master key") } @@ -94,7 +75,6 @@ func (s *MasterService) ValidateMasterKey(masterKey string) (*model.Master, erro return nil, errors.New("invalid master key") } -verified: if master.Status != "active" { return nil, fmt.Errorf("master is not active") }