mirror of
https://github.com/EZ-Api/ez-api.git
synced 2026-01-13 17:47:51 +00:00
Safeguard integer parsing in the `Whoami` handler by trimming whitespace and handling errors explicitly for `master_id`, `issued_at_epoch`, and `expires_at`. This prevents potential validation bypasses or incorrect behavior due to malformed metadata. Add unit tests to verify invalid epoch handling and response correctness.
270 lines
11 KiB
Go
270 lines
11 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
|
|
}
|
|
|
|
// 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"
|
|
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
|
|
}
|
|
|
|
// 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"`
|
|
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)
|
|
}
|
|
|
|
// 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) - 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 {
|
|
c.JSON(http.StatusOK, WhoamiResponse{
|
|
Type: "key",
|
|
ID: key.ID,
|
|
MasterID: uint(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),
|
|
AllowIPs: strings.TrimSpace(key.AllowIPs),
|
|
DenyIPs: strings.TrimSpace(key.DenyIPs),
|
|
ExpiresAt: expiresAt,
|
|
CreatedAt: key.CreatedAt.UTC().Unix(),
|
|
UpdatedAt: key.UpdatedAt.UTC().Unix(),
|
|
})
|
|
return
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"})
|
|
}
|