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