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 } // 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" 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 } // 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"` 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 // @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) - 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 { c.JSON(http.StatusOK, WhoamiResponse{ Type: "key", ID: key.ID, MasterID: uint(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), AllowIPs: strings.TrimSpace(key.AllowIPs), DenyIPs: strings.TrimSpace(key.DenyIPs), ExpiresAt: expiresAt, CreatedAt: key.CreatedAt.UTC().Unix(), UpdatedAt: key.UpdatedAt.UTC().Unix(), }) return } } c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) }