From 8e6d86edd74ba055259ff93f2c60a16ae0a2015c Mon Sep 17 00:00:00 2001 From: RC-CHN <1051989940@qq.com> Date: Thu, 25 Dec 2025 14:41:38 +0800 Subject: [PATCH] feat(api): add /auth/whoami endpoint for identity detection --- cmd/server/main.go | 7 ++ internal/api/auth_handler.go | 214 +++++++++++++++++++++++++++++++++++ 2 files changed, 221 insertions(+) create mode 100644 internal/api/auth_handler.go diff --git a/cmd/server/main.go b/cmd/server/main.go index 2af2573..f87e79b 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -203,6 +203,7 @@ func main() { masterHandler := api.NewMasterHandler(db, logDB, masterService, syncService, statsService, logPartitioner) internalHandler := api.NewInternalHandler(db) featureHandler := api.NewFeatureHandler(rdb) + authHandler := api.NewAuthHandler(db, rdb, adminService, masterService) modelRegistryService := service.NewModelRegistryService(db, rdb, service.ModelRegistryConfig{ Enabled: cfg.ModelRegistry.Enabled, RefreshEvery: time.Duration(cfg.ModelRegistry.RefreshSeconds) * time.Second, @@ -356,6 +357,12 @@ func main() { masterGroup.GET("/stats", masterHandler.GetSelfStats) } + // Auth Routes (public, no middleware - self-validates token) + authGroup := r.Group("/auth") + { + authGroup.GET("/whoami", authHandler.Whoami) + } + // Public/General Routes (if any) r.POST("/logs", handler.IngestLog) diff --git a/internal/api/auth_handler.go b/internal/api/auth_handler.go new file mode 100644 index 0000000..8a6fa9c --- /dev/null +++ b/internal/api/auth_handler.go @@ -0,0 +1,214 @@ +package api + +import ( + "net/http" + "strings" + + "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 +} + +// NewAuthHandler creates a new AuthHandler. +func NewAuthHandler(db *gorm.DB, rdb *redis.Client, adminService *service.AdminService, masterService *service.MasterService) *AuthHandler { + return &AuthHandler{ + db: db, + rdb: rdb, + adminService: adminService, + masterService: masterService, + } +} + +// 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" + CreatedAt int64 `json:"created_at" example:"1703505600"` // Unix timestamp + UpdatedAt int64 `json:"updated_at" example:"1703505600"` // Unix 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"` + + // 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"` + 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"` +} + +// 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", + }) + return + } + + // 2. Try master key + master, err := h.masterService.ValidateMasterKey(token) + if err == nil && master != nil { + c.JSON(http.StatusOK, 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(), + }) + return + } + + // 3. Try child key (API key) - lookup by token hash + tokenHash := tokenhash.HashToken(token) + + // First try Redis cache + 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 + } + } + } + } + + // 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" { + 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 + } + } + + c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) +}