Files
ez-api/internal/api/auth_handler.go
RC-CHN 7929b2a872 refactor(api): rename auth/whoami to users/info and simplify response
Rename endpoint from /auth/whoami to /users/info to align with
ez-contract schema. Simplify WhoamiResponse by removing:
- Realtime stats (requests, tokens, qps, rate_limited)
- Key-specific fields (allow_ips, deny_ips, expires_at, model_limits,
  quota fields, usage stats)

The endpoint now returns only essential identity information as
defined in ez-contract/schemas/auth/auth.yaml.

BREAKING CHANGE: /auth/whoami endpoint moved to /users/info with
reduced response fields
2026-01-13 15:57:52 +08:00

264 lines
9.0 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,
}
}
// WhoamiResponse is a union type for all identity responses.
// Based on ez-contract/schemas/auth/auth.yaml
// 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"`
}
// Whoami godoc
// @Summary Get current identity
// @Description Returns identity information of the authenticated user.
// @Description Supports Admin Token, Master Key, and Child Key (API Key) authentication.
// @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, default_namespace, namespaces, status, epoch, max_child_keys, global_qps
// @Description - Timestamps: created_at, updated_at
// @Description
// @Description **Child Key (API Key):**
// @Description - type: "key"
// @Description - Basic info: id, master_id, master_name, group, scopes, default_namespace, namespaces, status
// @Description - Security: issued_at_epoch, issued_by
// @Description - Timestamps: created_at, updated_at
// @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 /users/info [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(),
}
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
}
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),
CreatedAt: key.CreatedAt.UTC().Unix(),
UpdatedAt: key.UpdatedAt.UTC().Unix(),
}
c.JSON(http.StatusOK, resp)
return
}
}
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
}