mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
Add response envelope middleware to standardize JSON responses as
`{code,data,message}` with consistent business codes across endpoints.
Update Swagger annotations and tests to reflect the new response shape.
BREAKING CHANGE: API responses are now wrapped in a response envelope; clients must read payloads from `data` and handle `code`/`message` fields.
400 lines
16 KiB
Go
400 lines
16 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,
|
|
}
|
|
}
|
|
|
|
// 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=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",
|
|
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"})
|
|
}
|