Files
Luminary/internal/handler/admin/auth.go
T
renjue 76ba500417
CI / docker (push) Successful in 2m4s
Initial commit: Luminary AI Gateway
OpenAI-compatible AI gateway with Vue admin UI, multi-provider egress,
ingress key governance, monitoring, and security controls.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-23 21:46:16 +08:00

274 lines
9.3 KiB
Go

package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/rose_cat707/luminary/internal/auth"
"github.com/rose_cat707/luminary/internal/auth/loginlimit"
"github.com/rose_cat707/luminary/internal/gateway"
"github.com/rose_cat707/luminary/internal/middleware"
"github.com/rose_cat707/luminary/internal/model"
"github.com/rose_cat707/luminary/internal/monitor"
"github.com/rose_cat707/luminary/internal/prediction"
"github.com/rose_cat707/luminary/internal/settings"
"github.com/rose_cat707/luminary/internal/storage/imagestore"
"github.com/rose_cat707/luminary/internal/store/cache"
sqlitestore "github.com/rose_cat707/luminary/internal/store/sqlite"
)
type Handler struct {
repo *sqlitestore.Repository
db *sqlitestore.Store
cache *cache.Store
gateway *gateway.Service
settings *settings.Runtime
monitor *monitor.Service
loginLimit *loginlimit.Limiter
predictions *prediction.Service
images *imagestore.Store
publicURL func() string
}
func New(repo *sqlitestore.Repository, db *sqlitestore.Store, c *cache.Store, gw *gateway.Service, rt *settings.Runtime, mon *monitor.Service, pred *prediction.Service, imgs *imagestore.Store, publicURL func() string) *Handler {
return &Handler{
repo: repo,
db: db,
cache: c,
gateway: gw,
settings: rt,
monitor: mon,
loginLimit: loginlimit.New(rt.LoginMaxAttempts(), rt.LoginLockout()),
predictions: pred,
images: imgs,
publicURL: publicURL,
}
}
func (h *Handler) publicBaseURL() string {
if h.publicURL != nil {
return h.publicURL()
}
return "http://localhost:8293"
}
func (h *Handler) Register(r *gin.RouterGroup) {
r.POST("/login", h.Login)
r.GET("/auth/status", h.AuthStatus)
authGroup := r.Group("", middleware.AdminAuth(h.validateSession))
authGroup.POST("/logout", h.Logout)
authGroup.PUT("/password", h.ChangePassword)
authGroup.GET("/endpoint-meta", h.ListEndpointMeta)
authGroup.GET("/providers", h.ListProviders)
authGroup.POST("/providers", h.CreateProvider)
authGroup.GET("/providers/:id", h.GetProvider)
authGroup.PUT("/providers/:id", h.UpdateProvider)
authGroup.DELETE("/providers/:id", h.DeleteProvider)
authGroup.POST("/providers/:id/sync-models", h.SyncModels)
authGroup.GET("/providers/:id/keys", h.ListProviderKeys)
authGroup.POST("/providers/:id/keys", h.CreateProviderKey)
authGroup.PUT("/provider-keys/:keyId", h.UpdateProviderKey)
authGroup.DELETE("/provider-keys/:keyId", h.DeleteProviderKey)
authGroup.GET("/providers/:id/models", h.ListModels)
authGroup.POST("/providers/:id/models", h.CreateModel)
authGroup.PUT("/providers/:id/models/:modelId", h.UpdateModel)
authGroup.DELETE("/providers/:id/models/:modelId", h.DeleteModel)
authGroup.PUT("/models/:modelId", h.UpdateModel)
authGroup.DELETE("/models/:modelId", h.DeleteModel)
authGroup.GET("/ingress-keys", h.ListIngressKeys)
authGroup.POST("/ingress-keys", h.CreateIngressKey)
authGroup.GET("/ingress-keys/:id", h.GetIngressKey)
authGroup.PUT("/ingress-keys/:id", h.UpdateIngressKey)
authGroup.DELETE("/ingress-keys/:id", h.DeleteIngressKey)
authGroup.GET("/ingress-keys/:id/usage", h.IngressUsage)
authGroup.GET("/ingress-keys/:id/routing-rules", h.ListRoutingRules)
authGroup.POST("/ingress-keys/:id/routing-rules", h.CreateRoutingRule)
authGroup.PUT("/routing-rules/:ruleId", h.UpdateRoutingRule)
authGroup.DELETE("/routing-rules/:ruleId", h.DeleteRoutingRule)
authGroup.GET("/workflows/param-meta", h.GetWorkflowParamMeta)
authGroup.POST("/workflows/analyze", h.AnalyzeWorkflow)
authGroup.GET("/predictions/:id", h.GetPrediction)
authGroup.GET("/images/:id", h.ServeAdminImage)
authGroup.GET("/request-logs", h.ListRequestLogs)
authGroup.GET("/request-logs/:id", h.GetRequestLog)
authGroup.GET("/pricing/catalog", h.PricingCatalog)
authGroup.GET("/ip-rules", h.ListIPRules)
authGroup.POST("/ip-rules", h.CreateIPRule)
authGroup.PUT("/ip-rules/:id", h.UpdateIPRule)
authGroup.DELETE("/ip-rules/:id", h.DeleteIPRule)
authGroup.GET("/settings", h.GetSettings)
authGroup.PUT("/settings", h.UpdateSettings)
authGroup.GET("/dashboard/overview", h.DashboardOverview)
authGroup.GET("/dashboard/providers", h.DashboardProviders)
authGroup.GET("/dashboard/ingress-usage", h.DashboardIngressUsage)
authGroup.GET("/dashboard/egress-usage", h.DashboardEgressUsage)
authGroup.GET("/dashboard/provider-model-usage", h.DashboardProviderModelUsage)
authGroup.GET("/dashboard/client-ip-usage", h.DashboardClientIPUsage)
authGroup.GET("/dashboard/request-trend", h.DashboardRequestTrend)
}
func (h *Handler) validateSession(token string) (bool, time.Time) {
hash := auth.HashToken(token)
var session model.AdminSession
if err := h.db.DB().Where("token_hash = ?", hash).First(&session).Error; err != nil {
return false, time.Time{}
}
return session.ExpiresAt.After(time.Now()), session.ExpiresAt
}
func (h *Handler) Login(c *gin.Context) {
ip := middleware.ClientIP(c)
if locked, retryAfter := h.loginLimit.Check(ip); locked {
sec := int(retryAfter.Seconds()) + 1
c.Header("Retry-After", strconv.Itoa(sec))
h.recordLoginLog(ip, "", "error", "登录尝试次数过多", "", fmt.Sprintf(`{"retry_after_sec":%d}`, sec))
c.JSON(http.StatusTooManyRequests, gin.H{
"error": "登录尝试次数过多,请稍后再试",
"retry_after_sec": sec,
})
return
}
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := c.ShouldBindJSON(&req); err != nil {
h.recordLoginLog(ip, "", "error", "invalid request", "", "")
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
reqBody := loginRequestBody(req.Username)
var user model.AdminUser
if err := h.db.DB().Where("username = ?", req.Username).First(&user).Error; err != nil {
h.loginLimit.RecordFailure(ip)
h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
if !auth.CheckPassword(user.PasswordHash, req.Password) {
h.loginLimit.RecordFailure(ip)
h.recordLoginLog(ip, req.Username, "error", "invalid credentials", reqBody, "")
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid credentials"})
return
}
h.loginLimit.Reset(ip)
token, err := generateSessionToken()
if err != nil {
h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "")
c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"})
return
}
session := model.AdminSession{
TokenHash: auth.HashToken(token),
ExpiresAt: time.Now().Add(h.settings.SessionTTL()),
}
if err := h.db.DB().Create(&session).Error; err != nil {
h.recordLoginLog(ip, req.Username, "error", "session error", reqBody, "")
c.JSON(http.StatusInternalServerError, gin.H{"error": "session error"})
return
}
respBody := fmt.Sprintf(`{"expires_at":%q}`, session.ExpiresAt.Format(time.RFC3339))
h.recordLoginLog(ip, req.Username, "success", "", reqBody, respBody)
c.SetSameSite(http.SameSiteStrictMode)
c.SetCookie(middleware.SessionCookieName(), token, int(h.settings.SessionTTL().Seconds()), "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"token": token, "expires_at": session.ExpiresAt})
}
func loginRequestBody(username string) string {
b, _ := json.Marshal(map[string]string{"username": username})
return string(b)
}
func (h *Handler) recordLoginLog(ip, username, status, errMsg, requestBody, responseBody string) {
h.repo.RecordRequestLog(model.UsageRecord{
ClientIP: ip,
Endpoint: model.EndpointAdminLogin,
Model: username,
Status: status,
ErrorMsg: errMsg,
RequestBody: requestBody,
ResponseBody: responseBody,
})
}
func (h *Handler) Logout(c *gin.Context) {
token, _ := c.Get("session_token")
if t, ok := token.(string); ok {
h.db.DB().Where("token_hash = ?", auth.HashToken(t)).Delete(&model.AdminSession{})
}
c.SetCookie(middleware.SessionCookieName(), "", -1, "/", "", false, true)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func (h *Handler) AuthStatus(c *gin.Context) {
token := c.GetHeader("Authorization")
if len(token) > 7 {
token = token[7:]
}
if token == "" {
if cookie, err := c.Cookie(middleware.SessionCookieName()); err == nil {
token = cookie
}
}
valid := false
if token != "" {
valid, _ = h.validateSession(token)
}
c.JSON(http.StatusOK, gin.H{"authenticated": valid})
}
func (h *Handler) ChangePassword(c *gin.Context) {
var req struct {
OldPassword string `json:"old_password"`
NewPassword string `json:"new_password"`
}
if err := c.ShouldBindJSON(&req); err != nil || req.NewPassword == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request"})
return
}
var user model.AdminUser
if err := h.db.DB().First(&user).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "user not found"})
return
}
if !auth.CheckPassword(user.PasswordHash, req.OldPassword) {
c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid old password"})
return
}
hash, err := auth.HashPassword(req.NewPassword)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "hash error"})
return
}
user.PasswordHash = hash
h.db.DB().Save(&user)
c.JSON(http.StatusOK, gin.H{"ok": true})
}
func generateSessionToken() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}