feat(auth): enhance token validation and internal access control

Refactor the `Whoami` handler to validate token metadata (status, expiration,
revocation) against Redis before database lookup, ensuring consistency with
balancer logic. Add `allow_ips`, `deny_ips`, and `expires_at` fields to
authentication responses.

Update internal middleware to support explicit anonymous access configuration
and harden security for unconfigured tokens.

Remove legacy fallback logic for master keys without digests.

BREAKING CHANGE: Internal endpoints now reject requests by default if no stats token is configured. To allow unauthenticated access, set `internal.allow_anonymous` to true.
BREAKING CHANGE: Support for legacy master keys without stored digests has been removed.
This commit is contained in:
zenfun
2026-01-03 16:04:04 +08:00
parent 295faa8e01
commit 4cd9b66a84
5 changed files with 93 additions and 57 deletions

View File

@@ -310,7 +310,7 @@ func main() {
// API Routes // API Routes
// Internal Routes // Internal Routes
internalGroup := r.Group("/internal") 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("/stats/flush", internalHandler.FlushStats)
internalGroup.POST("/apikey-stats/flush", internalHandler.FlushAPIKeyStats) internalGroup.POST("/apikey-stats/flush", internalHandler.FlushAPIKeyStats)

View File

@@ -2,7 +2,9 @@ package api
import ( import (
"net/http" "net/http"
"strconv"
"strings" "strings"
"time"
"github.com/ez-api/ez-api/internal/model" "github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service" "github.com/ez-api/ez-api/internal/service"
@@ -64,6 +66,8 @@ type WhoamiKeyResponse struct {
Status string `json:"status" example:"active"` // Status: active/suspended Status string `json:"status" example:"active"` // Status: active/suspended
IssuedAtEpoch int64 `json:"issued_at_epoch" example:"1"` // Master epoch when issued IssuedAtEpoch int64 `json:"issued_at_epoch" example:"1"` // Master epoch when issued
IssuedBy string `json:"issued_by" example:"master"` // "master" or "admin" 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 CreatedAt int64 `json:"created_at" example:"1703505600"` // Unix timestamp
UpdatedAt int64 `json:"updated_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"` Scopes string `json:"scopes,omitempty" example:"chat:write"`
IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"` IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"`
IssuedBy string `json:"issued_by,omitempty" example:"master"` 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 // Whoami godoc
@@ -157,45 +164,72 @@ func (h *AuthHandler) Whoami(c *gin.Context) {
return 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) tokenHash := tokenhash.HashToken(token)
// First try Redis cache // Validate token using Redis (same logic as balancer)
if h.rdb != nil { if h.rdb != nil {
keyData, err := h.rdb.HGetAll(c.Request.Context(), "auth:token:"+tokenHash).Result() ctx := c.Request.Context()
if err == nil && len(keyData) > 0 && keyData["status"] == "active" {
// Found in Redis, now get full data from DB // Get token metadata from Redis
var key model.Key keyData, err := h.rdb.HGetAll(ctx, "auth:token:"+tokenHash).Result()
if err := h.db.Where("token_hash = ?", tokenHash).First(&key).Error; err == nil { if err != nil || len(keyData) == 0 {
if key.Status == "active" { c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
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 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
} }
// Fallback: direct database lookup // 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 var key model.Key
if err := h.db.Where("token_hash = ?", tokenHash).First(&key).Error; err == nil { if err := h.db.Where("token_hash = ?", tokenHash).First(&key).Error; err == nil {
if key.Status == "active" {
c.JSON(http.StatusOK, WhoamiResponse{ c.JSON(http.StatusOK, WhoamiResponse{
Type: "key", Type: "key",
ID: key.ID, ID: key.ID,
MasterID: key.MasterID, MasterID: uint(masterID),
Group: key.Group, Group: key.Group,
Scopes: strings.TrimSpace(key.Scopes), Scopes: strings.TrimSpace(key.Scopes),
DefaultNamespace: strings.TrimSpace(key.DefaultNamespace), DefaultNamespace: strings.TrimSpace(key.DefaultNamespace),
@@ -203,6 +237,9 @@ func (h *AuthHandler) Whoami(c *gin.Context) {
Status: key.Status, Status: key.Status,
IssuedAtEpoch: key.IssuedAtEpoch, IssuedAtEpoch: key.IssuedAtEpoch,
IssuedBy: strings.TrimSpace(key.IssuedBy), IssuedBy: strings.TrimSpace(key.IssuedBy),
AllowIPs: strings.TrimSpace(key.AllowIPs),
DenyIPs: strings.TrimSpace(key.DenyIPs),
ExpiresAt: expiresAt,
CreatedAt: key.CreatedAt.UTC().Unix(), CreatedAt: key.CreatedAt.UTC().Unix(),
UpdatedAt: key.UpdatedAt.UTC().Unix(), UpdatedAt: key.UpdatedAt.UTC().Unix(),
}) })

View File

@@ -72,6 +72,7 @@ type QuotaConfig struct {
type InternalConfig struct { type InternalConfig struct {
StatsToken string StatsToken string
AllowAnonymous bool
} }
type SyncOutboxConfig struct { type SyncOutboxConfig struct {
@@ -115,6 +116,7 @@ func Load() (*Config, error) {
v.SetDefault("model_registry.timeout_seconds", 30) v.SetDefault("model_registry.timeout_seconds", 30)
v.SetDefault("quota.reset_interval_seconds", 300) v.SetDefault("quota.reset_interval_seconds", 300)
v.SetDefault("internal.stats_token", "") v.SetDefault("internal.stats_token", "")
v.SetDefault("internal.allow_anonymous", false)
v.SetDefault("sync_outbox.enabled", true) v.SetDefault("sync_outbox.enabled", true)
v.SetDefault("sync_outbox.interval_seconds", 5) v.SetDefault("sync_outbox.interval_seconds", 5)
v.SetDefault("sync_outbox.batch_size", 200) 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("model_registry.timeout_seconds", "EZ_MODEL_REGISTRY_TIMEOUT_SECONDS")
_ = v.BindEnv("quota.reset_interval_seconds", "EZ_QUOTA_RESET_INTERVAL_SECONDS") _ = v.BindEnv("quota.reset_interval_seconds", "EZ_QUOTA_RESET_INTERVAL_SECONDS")
_ = v.BindEnv("internal.stats_token", "EZ_INTERNAL_STATS_TOKEN") _ = 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.enabled", "EZ_SYNC_OUTBOX_ENABLED")
_ = v.BindEnv("sync_outbox.interval_seconds", "EZ_SYNC_OUTBOX_INTERVAL_SECONDS") _ = v.BindEnv("sync_outbox.interval_seconds", "EZ_SYNC_OUTBOX_INTERVAL_SECONDS")
_ = v.BindEnv("sync_outbox.batch_size", "EZ_SYNC_OUTBOX_BATCH_SIZE") _ = v.BindEnv("sync_outbox.batch_size", "EZ_SYNC_OUTBOX_BATCH_SIZE")
@@ -217,6 +220,7 @@ func Load() (*Config, error) {
}, },
Internal: InternalConfig{ Internal: InternalConfig{
StatsToken: v.GetString("internal.stats_token"), StatsToken: v.GetString("internal.stats_token"),
AllowAnonymous: v.GetBool("internal.allow_anonymous"),
}, },
SyncOutbox: SyncOutboxConfig{ SyncOutbox: SyncOutboxConfig{
Enabled: v.GetBool("sync_outbox.enabled"), Enabled: v.GetBool("sync_outbox.enabled"),

View File

@@ -1,20 +1,35 @@
package middleware package middleware
import ( import (
"log/slog"
"net/http" "net/http"
"strings" "strings"
"github.com/gin-gonic/gin" "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) expectedToken = strings.TrimSpace(expectedToken)
return func(c *gin.Context) { 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() c.Next()
return 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")) token := strings.TrimSpace(c.GetHeader("X-Internal-Token"))
if token == "" || token != expectedToken { if token == "" || token != expectedToken {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid internal token"}) c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid internal token"})

View File

@@ -68,25 +68,6 @@ func (s *MasterService) ValidateMasterKey(masterKey string) (*model.Master, erro
var master model.Master var master model.Master
if err := s.db.Where("master_key_digest = ?", digest).First(&master).Error; err != nil { 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") 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") return nil, errors.New("invalid master key")
} }
verified:
if master.Status != "active" { if master.Status != "active" {
return nil, fmt.Errorf("master is not active") return nil, fmt.Errorf("master is not active")
} }