mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
feat(api): add /auth/whoami endpoint for identity detection
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
214
internal/api/auth_handler.go
Normal file
214
internal/api/auth_handler.go
Normal file
@@ -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"})
|
||||
}
|
||||
Reference in New Issue
Block a user