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 complete identity and realtime statistics of the authenticated user. // @Description Supports Admin Token, Master Key, and Child Key (API Key) authentication. // @Description This endpoint is designed for frontend initialization - call once after login // @Description and store the response for subsequent use. // @Description // @Description **Response varies by token type:** // @Description // @Description **Admin Token:** // @Description - type: "admin" // @Description - role: "admin" // @Description - permissions: ["*"] (full access) // @Description // @Description **Master Key:** // @Description - type: "master" // @Description - Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps // @Description - Timestamps: created_at, updated_at // @Description - Realtime stats: requests, tokens, qps, qps_limit, rate_limited // @Description // @Description **Child Key (API Key):** // @Description - type: "key" // @Description - Basic info: id, master_id, master_name, group, scopes, namespaces, status // @Description - Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at // @Description - Model limits: model_limits, model_limits_enabled // @Description - Quota: quota_limit, quota_used, quota_reset_at, quota_reset_type // @Description - Usage stats: request_count, used_tokens, last_accessed_at // @Description - Realtime stats: requests, tokens, qps, qps_limit, rate_limited // @Description // @Description **Error responses:** // @Description - 401: authorization header required // @Description - 401: invalid authorization header format // @Description - 401: invalid token // @Description - 401: token is not active // @Description - 401: token has expired // @Description - 401: token has been revoked // @Description - 401: master is not active // @Tags auth // @Produce json // @Security AdminAuth // @Security MasterAuth // @Success 200 {object} ResponseEnvelope{data=WhoamiResponse} // @Failure 401 {object} ResponseEnvelope{data=MapData} "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"}) }