Files
ez-api/internal/api/auth_handler.go
RC-CHN 1ee6bea413 feat(api): enhance whoami endpoint with realtime stats and extended key info
Add realtime statistics (requests, tokens, QPS, rate limiting) to whoami
response for both master and key authentication types. Extend key response
with additional fields including master name, model limits, quota tracking,
and usage statistics.

- Inject StatsService into AuthHandler for realtime stats retrieval
- Add WhoamiRealtimeView struct for realtime statistics
- Include admin permissions field in admin response
- Add comprehensive key metadata (quotas, model limits, usage stats)
- Add test for expired key returning 401 Unauthorized
2026-01-06 09:15:49 +08:00

372 lines
15 KiB
Go

package api
import (
"net/http"
"strconv"
"strings"
"time"
"github.com/ez-api/ez-api/internal/model"
"github.com/ez-api/ez-api/internal/service"
"github.com/ez-api/foundation/tokenhash"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
// AuthHandler handles authentication identity endpoints.
type AuthHandler struct {
db *gorm.DB
rdb *redis.Client
adminService *service.AdminService
masterService *service.MasterService
statsService *service.StatsService
}
// NewAuthHandler creates a new AuthHandler.
func NewAuthHandler(db *gorm.DB, rdb *redis.Client, adminService *service.AdminService, masterService *service.MasterService, statsService *service.StatsService) *AuthHandler {
return &AuthHandler{
db: db,
rdb: rdb,
adminService: adminService,
masterService: masterService,
statsService: statsService,
}
}
// WhoamiAdminResponse represents admin identity response.
type WhoamiAdminResponse struct {
Type string `json:"type" example:"admin"` // Always "admin"
Role string `json:"role" example:"admin"` // Admin role
}
// WhoamiMasterResponse represents master identity response.
type WhoamiMasterResponse struct {
Type string `json:"type" example:"master"` // Always "master"
ID uint `json:"id" example:"1"` // Master ID
Name string `json:"name" example:"tenant-a"` // Master name
Group string `json:"group" example:"default"` // Routing group
DefaultNamespace string `json:"default_namespace" example:"default"` // Default namespace
Namespaces string `json:"namespaces" example:"default,ns1"` // Comma-separated namespaces
Status string `json:"status" example:"active"` // Status: active/suspended
Epoch int64 `json:"epoch" example:"1"` // Epoch for key revocation
MaxChildKeys int `json:"max_child_keys" example:"5"` // Max child keys allowed
GlobalQPS int `json:"global_qps" example:"100"` // Global QPS limit
CreatedAt int64 `json:"created_at" example:"1703505600"` // Unix timestamp
UpdatedAt int64 `json:"updated_at" example:"1703505600"` // Unix timestamp
}
// WhoamiKeyResponse represents child key identity response.
type WhoamiKeyResponse struct {
Type string `json:"type" example:"key"` // Always "key"
ID uint `json:"id" example:"5"` // Key ID
MasterID uint `json:"master_id" example:"1"` // Parent master ID
Group string `json:"group" example:"default"` // Routing group
Scopes string `json:"scopes" example:"chat:write"` // Comma-separated scopes
DefaultNamespace string `json:"default_namespace" example:"default"` // Default namespace
Namespaces string `json:"namespaces" example:"default"` // Comma-separated namespaces
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
}
// WhoamiRealtimeView represents realtime statistics for the authenticated identity.
type WhoamiRealtimeView struct {
Requests int64 `json:"requests" example:"100"` // Total requests
Tokens int64 `json:"tokens" example:"50000"` // Total tokens used
QPS int64 `json:"qps" example:"5"` // Current QPS
QPSLimit int64 `json:"qps_limit" example:"100"` // QPS limit
RateLimited bool `json:"rate_limited" example:"false"` // Whether currently rate limited
UpdatedAt *int64 `json:"updated_at,omitempty" example:"1703505600"` // Last updated timestamp
}
// WhoamiResponse is a union type for all identity responses.
// swagger:model WhoamiResponse
type WhoamiResponse struct {
// Type of the authenticated identity: "admin", "master", or "key"
Type string `json:"type" example:"master"`
// Admin fields (only present when type is "admin")
Role string `json:"role,omitempty" example:"admin"`
Permissions []string `json:"permissions,omitempty"` // Admin permissions (always ["*"])
// Master fields (only present when type is "master")
ID uint `json:"id,omitempty" example:"1"`
Name string `json:"name,omitempty" example:"tenant-a"`
Group string `json:"group,omitempty" example:"default"`
DefaultNamespace string `json:"default_namespace,omitempty" example:"default"`
Namespaces string `json:"namespaces,omitempty" example:"default,ns1"`
Status string `json:"status,omitempty" example:"active"`
Epoch int64 `json:"epoch,omitempty" example:"1"`
MaxChildKeys int `json:"max_child_keys,omitempty" example:"5"`
GlobalQPS int `json:"global_qps,omitempty" example:"100"`
CreatedAt int64 `json:"created_at,omitempty" example:"1703505600"`
UpdatedAt int64 `json:"updated_at,omitempty" example:"1703505600"`
// Key fields (only present when type is "key")
MasterID uint `json:"master_id,omitempty" example:"1"`
MasterName string `json:"master_name,omitempty" example:"tenant-a"` // Parent master name (for display)
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)
ModelLimits string `json:"model_limits,omitempty" example:"gpt-4,claude"` // Model restrictions
ModelLimitsEnabled bool `json:"model_limits_enabled,omitempty" example:"false"` // Whether model limits are active
QuotaLimit int64 `json:"quota_limit,omitempty" example:"-1"` // Token quota limit (-1 = unlimited)
QuotaUsed int64 `json:"quota_used,omitempty" example:"0"` // Token quota used
QuotaResetAt int64 `json:"quota_reset_at,omitempty" example:"0"` // Quota reset timestamp
QuotaResetType string `json:"quota_reset_type,omitempty" example:"monthly"` // Quota reset type
RequestCount int64 `json:"request_count,omitempty" example:"0"` // Total request count (from DB)
UsedTokens int64 `json:"used_tokens,omitempty" example:"0"` // Total tokens used (from DB)
LastAccessedAt int64 `json:"last_accessed_at,omitempty" example:"0"` // Last access timestamp
// Realtime stats (for master and key types)
Realtime *WhoamiRealtimeView `json:"realtime,omitempty"`
}
// Whoami godoc
// @Summary Get current identity
// @Description Returns the identity of the authenticated user based on the Authorization header.
// @Description Supports Admin Token, Master Key, and Child Key (API Key) authentication.
// @Description
// @Description Response varies by token type:
// @Description - Admin Token: {"type": "admin", "role": "admin"}
// @Description - Master Key: {"type": "master", "id": 1, "name": "...", ...}
// @Description - Child Key: {"type": "key", "id": 5, "master_id": 1, "issued_by": "master", ...}
// @Tags auth
// @Produce json
// @Security AdminAuth
// @Security MasterAuth
// @Success 200 {object} WhoamiResponse
// @Failure 401 {object} gin.H "Invalid or missing token"
// @Router /auth/whoami [get]
func (h *AuthHandler) Whoami(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "authorization header required"})
return
}
parts := strings.Split(authHeader, " ")
if len(parts) != 2 || parts[0] != "Bearer" {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid authorization header format"})
return
}
token := parts[1]
// 1. Try admin token first
if h.adminService.ValidateToken(token) {
c.JSON(http.StatusOK, WhoamiResponse{
Type: "admin",
Role: "admin",
Permissions: []string{"*"}, // Admin has all permissions
})
return
}
// 2. Try master key
master, err := h.masterService.ValidateMasterKey(token)
if err == nil && master != nil {
resp := WhoamiResponse{
Type: "master",
ID: master.ID,
Name: master.Name,
Group: master.Group,
DefaultNamespace: strings.TrimSpace(master.DefaultNamespace),
Namespaces: strings.TrimSpace(master.Namespaces),
Status: master.Status,
Epoch: master.Epoch,
MaxChildKeys: master.MaxChildKeys,
GlobalQPS: master.GlobalQPS,
CreatedAt: master.CreatedAt.UTC().Unix(),
UpdatedAt: master.UpdatedAt.UTC().Unix(),
}
// Get realtime stats for master
if h.statsService != nil {
if stats, err := h.statsService.GetMasterRealtimeSnapshot(c.Request.Context(), master.ID); err == nil {
if stats.QPSLimit == 0 && master.GlobalQPS > 0 {
stats.QPSLimit = int64(master.GlobalQPS)
}
var updatedAt *int64
if stats.UpdatedAt != nil {
sec := stats.UpdatedAt.Unix()
updatedAt = &sec
}
resp.Realtime = &WhoamiRealtimeView{
Requests: stats.Requests,
Tokens: stats.Tokens,
QPS: stats.QPS,
QPSLimit: stats.QPSLimit,
RateLimited: stats.RateLimited,
UpdatedAt: updatedAt,
}
}
}
c.JSON(http.StatusOK, resp)
return
}
// 3. Try child key (API key) - validate using Redis first (consistent with balancer)
tokenHash := tokenhash.HashToken(token)
// Validate token using Redis (same logic as balancer)
if h.rdb != nil {
ctx := c.Request.Context()
// 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 := strings.TrimSpace(keyData["master_id"])
masterID, err := strconv.ParseUint(masterIDStr, 10, 64)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token metadata"})
return
}
issuedAtStr := strings.TrimSpace(keyData["issued_at_epoch"])
issuedAtEpoch, err := strconv.ParseInt(issuedAtStr, 10, 64)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token metadata"})
return
}
// 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)
masterEpochStr := strings.TrimSpace(masterData["epoch"])
masterEpoch, err := strconv.ParseInt(masterEpochStr, 10, 64)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid master metadata"})
return
}
if issuedAtEpoch < masterEpoch {
c.JSON(http.StatusUnauthorized, gin.H{"error": "token has been revoked"})
return
}
// Check expiration
expiresAt := int64(0)
expiresAtStr := strings.TrimSpace(keyData["expires_at"])
if expiresAtStr != "" {
expiresAt, err = strconv.ParseInt(expiresAtStr, 10, 64)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token metadata"})
return
}
}
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 {
// Get master name for display
var master model.Master
masterName := ""
if err := h.db.First(&master, masterID).Error; err == nil {
masterName = master.Name
}
// Convert nullable time fields
var quotaResetAt int64
if key.QuotaResetAt != nil {
quotaResetAt = key.QuotaResetAt.UTC().Unix()
}
var lastAccessedAt int64
if key.LastAccessedAt != nil {
lastAccessedAt = key.LastAccessedAt.UTC().Unix()
}
resp := WhoamiResponse{
Type: "key",
ID: key.ID,
MasterID: uint(masterID),
MasterName: masterName,
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),
AllowIPs: strings.TrimSpace(key.AllowIPs),
DenyIPs: strings.TrimSpace(key.DenyIPs),
ExpiresAt: expiresAt,
ModelLimits: strings.TrimSpace(key.ModelLimits),
ModelLimitsEnabled: key.ModelLimitsEnabled,
QuotaLimit: key.QuotaLimit,
QuotaUsed: key.QuotaUsed,
QuotaResetAt: quotaResetAt,
QuotaResetType: strings.TrimSpace(key.QuotaResetType),
RequestCount: key.RequestCount,
UsedTokens: key.UsedTokens,
LastAccessedAt: lastAccessedAt,
CreatedAt: key.CreatedAt.UTC().Unix(),
UpdatedAt: key.UpdatedAt.UTC().Unix(),
}
// Get realtime stats for the key's master
if h.statsService != nil {
if stats, err := h.statsService.GetMasterRealtimeSnapshot(c.Request.Context(), uint(masterID)); err == nil {
if stats.QPSLimit == 0 && master.GlobalQPS > 0 {
stats.QPSLimit = int64(master.GlobalQPS)
}
var updatedAt *int64
if stats.UpdatedAt != nil {
sec := stats.UpdatedAt.Unix()
updatedAt = &sec
}
resp.Realtime = &WhoamiRealtimeView{
Requests: stats.Requests,
Tokens: stats.Tokens,
QPS: stats.QPS,
QPSLimit: stats.QPSLimit,
RateLimited: stats.RateLimited,
UpdatedAt: updatedAt,
}
}
}
c.JSON(http.StatusOK, resp)
return
}
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
}