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
This commit is contained in:
2026-01-13 15:57:52 +08:00
parent c990fa6610
commit 7929b2a872
3 changed files with 49 additions and 173 deletions

View File

@@ -428,10 +428,10 @@ func main() {
masterGroup.GET("/stats", masterHandler.GetSelfStats) masterGroup.GET("/stats", masterHandler.GetSelfStats)
} }
// Auth Routes (public, no middleware - self-validates token) // Users Routes (public, no middleware - self-validates token)
authGroup := r.Group("/auth") usersGroup := r.Group("/users")
{ {
authGroup.GET("/whoami", authHandler.Whoami) usersGroup.GET("/info", authHandler.Whoami)
} }
// Public/General Routes (if any) // Public/General Routes (if any)

View File

@@ -34,57 +34,8 @@ func NewAuthHandler(db *gorm.DB, rdb *redis.Client, adminService *service.AdminS
} }
} }
// 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. // WhoamiResponse is a union type for all identity responses.
// Based on ez-contract/schemas/auth/auth.yaml
// swagger:model WhoamiResponse // swagger:model WhoamiResponse
type WhoamiResponse struct { type WhoamiResponse struct {
// Type of the authenticated identity: "admin", "master", or "key" // Type of the authenticated identity: "admin", "master", or "key"
@@ -108,34 +59,17 @@ type WhoamiResponse struct {
UpdatedAt int64 `json:"updated_at,omitempty" example:"1703505600"` UpdatedAt int64 `json:"updated_at,omitempty" example:"1703505600"`
// Key fields (only present when type is "key") // Key fields (only present when type is "key")
MasterID uint `json:"master_id,omitempty" example:"1"` MasterID uint `json:"master_id,omitempty" example:"1"`
MasterName string `json:"master_name,omitempty" example:"tenant-a"` // Parent master name (for display) MasterName string `json:"master_name,omitempty" example:"tenant-a"` // Parent master name (for display)
Scopes string `json:"scopes,omitempty" example:"chat:write"` Scopes string `json:"scopes,omitempty" example:"chat:write"`
IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"` IssuedAtEpoch int64 `json:"issued_at_epoch,omitempty" example:"1"`
IssuedBy string `json:"issued_by,omitempty" example:"master"` 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 // Whoami godoc
// @Summary Get current identity // @Summary Get current identity
// @Description Returns complete identity and realtime statistics of the authenticated user. // @Description Returns identity information of the authenticated user.
// @Description Supports Admin Token, Master Key, and Child Key (API Key) authentication. // @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
// @Description **Response varies by token type:** // @Description **Response varies by token type:**
// @Description // @Description
@@ -146,18 +80,14 @@ type WhoamiResponse struct {
// @Description // @Description
// @Description **Master Key:** // @Description **Master Key:**
// @Description - type: "master" // @Description - type: "master"
// @Description - Basic info: id, name, group, namespaces, status, epoch, max_child_keys, global_qps // @Description - Basic info: id, name, group, default_namespace, namespaces, status, epoch, max_child_keys, global_qps
// @Description - Timestamps: created_at, updated_at // @Description - Timestamps: created_at, updated_at
// @Description - Realtime stats: requests, tokens, qps, qps_limit, rate_limited
// @Description // @Description
// @Description **Child Key (API Key):** // @Description **Child Key (API Key):**
// @Description - type: "key" // @Description - type: "key"
// @Description - Basic info: id, master_id, master_name, group, scopes, namespaces, status // @Description - Basic info: id, master_id, master_name, group, scopes, default_namespace, namespaces, status
// @Description - Security: issued_at_epoch, issued_by, allow_ips, deny_ips, expires_at // @Description - Security: issued_at_epoch, issued_by
// @Description - Model limits: model_limits, model_limits_enabled // @Description - Timestamps: created_at, updated_at
// @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
// @Description **Error responses:** // @Description **Error responses:**
// @Description - 401: authorization header required // @Description - 401: authorization header required
@@ -173,7 +103,7 @@ type WhoamiResponse struct {
// @Security MasterAuth // @Security MasterAuth
// @Success 200 {object} ResponseEnvelope{data=WhoamiResponse} // @Success 200 {object} ResponseEnvelope{data=WhoamiResponse}
// @Failure 401 {object} ResponseEnvelope{data=MapData} "Invalid or missing token" // @Failure 401 {object} ResponseEnvelope{data=MapData} "Invalid or missing token"
// @Router /auth/whoami [get] // @Router /users/info [get]
func (h *AuthHandler) Whoami(c *gin.Context) { func (h *AuthHandler) Whoami(c *gin.Context) {
authHeader := c.GetHeader("Authorization") authHeader := c.GetHeader("Authorization")
if authHeader == "" { if authHeader == "" {
@@ -217,28 +147,6 @@ func (h *AuthHandler) Whoami(c *gin.Context) {
UpdatedAt: master.UpdatedAt.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) c.JSON(http.StatusOK, resp)
return return
} }
@@ -330,64 +238,20 @@ func (h *AuthHandler) Whoami(c *gin.Context) {
masterName = master.Name 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{ resp := WhoamiResponse{
Type: "key", Type: "key",
ID: key.ID, ID: key.ID,
MasterID: uint(masterID), MasterID: uint(masterID),
MasterName: masterName, MasterName: masterName,
Group: key.Group, Group: key.Group,
Scopes: strings.TrimSpace(key.Scopes), Scopes: strings.TrimSpace(key.Scopes),
DefaultNamespace: strings.TrimSpace(key.DefaultNamespace), DefaultNamespace: strings.TrimSpace(key.DefaultNamespace),
Namespaces: strings.TrimSpace(key.Namespaces), Namespaces: strings.TrimSpace(key.Namespaces),
Status: key.Status, Status: key.Status,
IssuedAtEpoch: key.IssuedAtEpoch, IssuedAtEpoch: key.IssuedAtEpoch,
IssuedBy: strings.TrimSpace(key.IssuedBy), IssuedBy: strings.TrimSpace(key.IssuedBy),
AllowIPs: strings.TrimSpace(key.AllowIPs), CreatedAt: key.CreatedAt.UTC().Unix(),
DenyIPs: strings.TrimSpace(key.DenyIPs), UpdatedAt: key.UpdatedAt.UTC().Unix(),
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) c.JSON(http.StatusOK, resp)

View File

@@ -97,7 +97,8 @@ func TestAuthHandler_Whoami_InvalidMasterEpoch_Returns401(t *testing.T) {
} }
} }
func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) { // TestAuthHandler_Whoami_KeyResponseFormat tests the key response format per contract.
func TestAuthHandler_Whoami_KeyResponseFormat(t *testing.T) {
handler, db, mr := newAuthHandler(t) handler, db, mr := newAuthHandler(t)
token := "sk-live-valid" token := "sk-live-valid"
@@ -131,9 +132,9 @@ func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) {
r := gin.New() r := gin.New()
r.Use(middleware.ResponseEnvelope()) r.Use(middleware.ResponseEnvelope())
r.GET("/auth/whoami", handler.Whoami) r.GET("/users/info", handler.Whoami)
req := httptest.NewRequest(http.MethodGet, "/auth/whoami", nil) req := httptest.NewRequest(http.MethodGet, "/users/info", nil)
req.Header.Set("Authorization", "Bearer "+token) req.Header.Set("Authorization", "Bearer "+token)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
r.ServeHTTP(rr, req) r.ServeHTTP(rr, req)
@@ -150,14 +151,25 @@ func TestAuthHandler_Whoami_KeyResponseIncludesIPRules(t *testing.T) {
if env.Message != "success" { if env.Message != "success" {
t.Fatalf("expected message=success, got %q", env.Message) t.Fatalf("expected message=success, got %q", env.Message)
} }
if resp["allow_ips"] != "1.2.3.4" { // Verify contract-defined fields are present
t.Fatalf("expected allow_ips, got %v", resp["allow_ips"]) if resp["type"] != "key" {
t.Fatalf("expected type=key, got %v", resp["type"])
} }
if resp["deny_ips"] != "5.6.7.0/24" { if resp["scopes"] != "chat:write" {
t.Fatalf("expected deny_ips, got %v", resp["deny_ips"]) t.Fatalf("expected scopes=chat:write, got %v", resp["scopes"])
} }
if got, ok := resp["expires_at"].(float64); !ok || int64(got) != expiresAt { if resp["issued_by"] != "master" {
t.Fatalf("expected expires_at=%d, got %v", expiresAt, resp["expires_at"]) t.Fatalf("expected issued_by=master, got %v", resp["issued_by"])
}
// Verify removed fields are not present (per contract)
if _, exists := resp["allow_ips"]; exists {
t.Fatalf("allow_ips should not be in response per contract")
}
if _, exists := resp["deny_ips"]; exists {
t.Fatalf("deny_ips should not be in response per contract")
}
if _, exists := resp["expires_at"]; exists {
t.Fatalf("expires_at should not be in response per contract")
} }
} }