From 98666ab1eaa117571b423e42a21c903204c54d44 Mon Sep 17 00:00:00 2001 From: CGH0S7 <776459475@qq.com> Date: Wed, 15 Apr 2026 11:10:52 +0800 Subject: [PATCH] Rework admin console authentication and UI --- backend/cmd/server/main.go | 2 +- backend/internal/api/admin.go | 95 ++- backend/internal/api/auth.go | 16 + backend/internal/api/middleware.go | 102 ++- backend/internal/api/router.go | 5 +- backend/internal/api/static/admin/index.html | 701 +++++++++++++------ backend/internal/db/db.go | 4 +- 7 files changed, 650 insertions(+), 275 deletions(-) diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go index db88312..2498543 100644 --- a/backend/cmd/server/main.go +++ b/backend/cmd/server/main.go @@ -10,7 +10,7 @@ import ( func main() { monitor.Init(2000) - monitor.Infof("Starting Hightube Server v1.0.0-Beta3.7") + monitor.Infof("Starting Hightube Server v1.0.0-Beta4.1") // Initialize Database and run auto-migrations db.InitDB() diff --git a/backend/internal/api/admin.go b/backend/internal/api/admin.go index eed6783..9cb716a 100644 --- a/backend/internal/api/admin.go +++ b/backend/internal/api/admin.go @@ -22,6 +22,64 @@ func BindAdminDependencies(rtmpSrv *stream.RTMPServer) { adminRTMP = rtmpSrv } +func AdminLogin(c *gin.Context) { + var req LoginRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var user model.User + if err := db.DB.Where("username = ?", strings.TrimSpace(req.Username)).First(&user).Error; err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + return + } + + if !utils.CheckPasswordHash(req.Password, user.Password) { + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid username or password"}) + return + } + + if !user.Enabled { + c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"}) + return + } + + if user.Role != "admin" { + c.JSON(http.StatusForbidden, gin.H{"error": "Admin access required"}) + return + } + + token, err := utils.GenerateToken(user.ID, user.Username, user.Role) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create admin session"}) + return + } + + c.SetCookie(adminSessionCookieName, token, 86400, "/", "", false, true) + monitor.Auditf("admin=%s signed in", user.Username) + c.JSON(http.StatusOK, gin.H{ + "username": user.Username, + "role": user.Role, + }) +} + +func AdminLogout(c *gin.Context) { + operator, _ := c.Get("username") + c.SetCookie(adminSessionCookieName, "", -1, "/", "", false, true) + monitor.Auditf("admin=%v signed out", operator) + c.JSON(http.StatusOK, gin.H{"message": "signed out"}) +} + +func GetAdminSession(c *gin.Context) { + username, _ := c.Get("username") + role, _ := c.Get("role") + c.JSON(http.StatusOK, gin.H{ + "username": username, + "role": role, + }) +} + func GetAdminOverview(c *gin.Context) { stats := monitor.GetSnapshot() chatStats := chat.StatsSnapshot{} @@ -83,10 +141,6 @@ func ListAdminLogs(c *gin.Context) { } func StreamAdminLogs(c *gin.Context) { - if !authorizeAdminTokenFromQuery(c) { - return - } - c.Header("Content-Type", "text/event-stream") c.Header("Cache-Control", "no-cache") c.Header("Connection", "keep-alive") @@ -114,39 +168,6 @@ func StreamAdminLogs(c *gin.Context) { } } -func authorizeAdminTokenFromQuery(c *gin.Context) bool { - token := strings.TrimSpace(c.Query("token")) - if token == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "token is required"}) - return false - } - - claims, err := utils.ParseToken(token) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token"}) - return false - } - - userID, err := strconv.ParseUint(claims.Subject, 10, 32) - if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "invalid token subject"}) - return false - } - - var user model.User - if err := db.DB.First(&user, uint(userID)).Error; err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "user not found"}) - return false - } - - if !user.Enabled || user.Role != "admin" { - c.JSON(http.StatusForbidden, gin.H{"error": "admin access required"}) - return false - } - - return true -} - type updateRoleRequest struct { Role string `json:"role" binding:"required"` } diff --git a/backend/internal/api/auth.go b/backend/internal/api/auth.go index b99023f..c9196d5 100644 --- a/backend/internal/api/auth.go +++ b/backend/internal/api/auth.go @@ -2,6 +2,8 @@ package api import ( "net/http" + "os" + "strings" "github.com/gin-gonic/gin" @@ -32,6 +34,12 @@ func Register(c *gin.Context) { return } + req.Username = strings.TrimSpace(req.Username) + if strings.EqualFold(req.Username, bootstrapAdminUsername()) { + c.JSON(http.StatusForbidden, gin.H{"error": "This username is reserved"}) + return + } + // Check if user exists var existingUser model.User if err := db.DB.Where("username = ?", req.Username).First(&existingUser).Error; err == nil { @@ -146,3 +154,11 @@ func ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"message": "Password updated successfully"}) } + +func bootstrapAdminUsername() string { + adminUsername := strings.TrimSpace(os.Getenv("HIGHTUBE_ADMIN_USER")) + if adminUsername == "" { + return "admin" + } + return adminUsername +} diff --git a/backend/internal/api/middleware.go b/backend/internal/api/middleware.go index 7fea0a1..897f111 100644 --- a/backend/internal/api/middleware.go +++ b/backend/internal/api/middleware.go @@ -1,6 +1,7 @@ package api import ( + "errors" "net/http" "strconv" "strings" @@ -13,47 +14,30 @@ import ( "hightube/internal/utils" ) +const adminSessionCookieName = "hightube_admin_session" + // AuthMiddleware intercepts requests, validates JWT, and injects user_id into context func AuthMiddleware() gin.HandlerFunc { return func(c *gin.Context) { - authHeader := c.GetHeader("Authorization") - if authHeader == "" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header is required"}) - c.Abort() - return - } - - parts := strings.Split(authHeader, " ") - if len(parts) != 2 || parts[0] != "Bearer" { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization header format must be Bearer {token}"}) - c.Abort() - return - } - - tokenStr := parts[1] - claims, err := utils.ParseToken(tokenStr) + user, err := authenticateRequest(c) if err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + switch { + case errors.Is(err, errMissingToken): + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authorization is required"}) + case errors.Is(err, errInvalidToken): + c.JSON(http.StatusUnauthorized, gin.H{"error": "Invalid or expired token"}) + case errors.Is(err, errUserNotFound): + c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) + case errors.Is(err, errDisabledAccount): + c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"}) + default: + c.JSON(http.StatusUnauthorized, gin.H{"error": "Authentication failed"}) + } c.Abort() return } - userID, _ := strconv.ParseUint(claims.Subject, 10, 32) - - var user model.User - if err := db.DB.First(&user, uint(userID)).Error; err != nil { - c.JSON(http.StatusUnauthorized, gin.H{"error": "User not found"}) - c.Abort() - return - } - - if !user.Enabled { - c.JSON(http.StatusForbidden, gin.H{"error": "Account is disabled"}) - c.Abort() - return - } - - c.Set("user_id", uint(userID)) + c.Set("user_id", user.ID) c.Set("username", user.Username) c.Set("role", user.Role) c.Next() @@ -82,6 +66,58 @@ func RequestMetricsMiddleware() gin.HandlerFunc { } } +var ( + errMissingToken = errors.New("missing token") + errInvalidToken = errors.New("invalid token") + errUserNotFound = errors.New("user not found") + errDisabledAccount = errors.New("disabled account") +) + +func authenticateRequest(c *gin.Context) (*model.User, error) { + tokenStr := extractToken(c) + if tokenStr == "" { + return nil, errMissingToken + } + + claims, err := utils.ParseToken(tokenStr) + if err != nil { + return nil, errInvalidToken + } + + userID, err := strconv.ParseUint(claims.Subject, 10, 32) + if err != nil { + return nil, errInvalidToken + } + + var user model.User + if err := db.DB.First(&user, uint(userID)).Error; err != nil { + return nil, errUserNotFound + } + + if !user.Enabled { + return nil, errDisabledAccount + } + + return &user, nil +} + +func extractToken(c *gin.Context) string { + authHeader := strings.TrimSpace(c.GetHeader("Authorization")) + if authHeader != "" { + parts := strings.Split(authHeader, " ") + if len(parts) == 2 && parts[0] == "Bearer" { + return strings.TrimSpace(parts[1]) + } + } + + cookieToken, err := c.Cookie(adminSessionCookieName) + if err == nil { + return strings.TrimSpace(cookieToken) + } + + return "" +} + // CORSMiddleware handles cross-origin requests from web clients func CORSMiddleware() gin.HandlerFunc { return func(c *gin.Context) { diff --git a/backend/internal/api/router.go b/backend/internal/api/router.go index 46045f7..1a514a2 100644 --- a/backend/internal/api/router.go +++ b/backend/internal/api/router.go @@ -23,13 +23,14 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine { // Public routes r.POST("/api/register", Register) r.POST("/api/login", Login) + r.POST("/api/admin/login", AdminLogin) r.GET("/api/rooms/active", GetActiveRooms) r.GET("/live/:room_id", streamServer.HandleHTTPFLV) // WebSocket endpoint for live chat r.GET("/api/ws/room/:room_id", WSHandler) r.GET("/admin", AdminPage) - r.GET("/api/admin/logs/stream", StreamAdminLogs) + r.GET("/api/admin/logs/stream", AuthMiddleware(), AdminMiddleware(), StreamAdminLogs) // Protected routes (require JWT) authGroup := r.Group("/api") @@ -41,9 +42,11 @@ func SetupRouter(streamServer *stream.RTMPServer) *gin.Engine { adminGroup := authGroup.Group("/admin") adminGroup.Use(AdminMiddleware()) { + adminGroup.GET("/session", GetAdminSession) adminGroup.GET("/overview", GetAdminOverview) adminGroup.GET("/health", GetAdminHealth) adminGroup.GET("/logs", ListAdminLogs) + adminGroup.POST("/logout", AdminLogout) adminGroup.GET("/users", ListUsers) adminGroup.PATCH("/users/:id/role", UpdateUserRole) adminGroup.PATCH("/users/:id/enabled", UpdateUserEnabled) diff --git a/backend/internal/api/static/admin/index.html b/backend/internal/api/static/admin/index.html index 3c41435..9f5f135 100644 --- a/backend/internal/api/static/admin/index.html +++ b/backend/internal/api/static/admin/index.html @@ -1,5 +1,5 @@ - +
@@ -9,217 +9,431 @@ -