mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: direct database lookup
|
// 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"})
|
||||||
|
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{
|
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(),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ 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")
|
||||||
@@ -216,7 +219,8 @@ func Load() (*Config, error) {
|
|||||||
ResetIntervalSeconds: v.GetInt("quota.reset_interval_seconds"),
|
ResetIntervalSeconds: v.GetInt("quota.reset_interval_seconds"),
|
||||||
},
|
},
|
||||||
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"),
|
||||||
|
|||||||
@@ -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"})
|
||||||
|
|||||||
@@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user