feat(api): add /auth/whoami endpoint for identity detection

This commit is contained in:
2025-12-25 14:41:38 +08:00
parent b566eb8058
commit 8e6d86edd7
2 changed files with 221 additions and 0 deletions

View 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"})
}