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:
@@ -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(),
|
||||
})
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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"})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user