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

@@ -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(),
})