From 225e3d0302967b92b9e8adc15bcb0d3b190eb388 Mon Sep 17 00:00:00 2001 From: Deyu Han Date: Thu, 25 Dec 2025 13:18:16 -0800 Subject: [PATCH] Add read only user (#7862) * add readonly user * add args * address comments * avoid same user name * Prevents timing attacks * doc --------- Co-authored-by: Chris Lu --- weed/admin/Makefile | 4 +- weed/admin/dash/auth_middleware.go | 19 ++++++++- weed/admin/dash/middleware.go | 51 ++++++++++++++++++++-- weed/admin/handlers/admin_handlers.go | 61 +++++++++++++++------------ weed/admin/handlers/auth_handlers.go | 13 +++++- weed/command/admin.go | 52 ++++++++++++++++++----- weed/command/mini.go | 21 ++++++++- 7 files changed, 171 insertions(+), 50 deletions(-) diff --git a/weed/admin/Makefile b/weed/admin/Makefile index b79ddc1ab..605545d3b 100644 --- a/weed/admin/Makefile +++ b/weed/admin/Makefile @@ -160,6 +160,4 @@ $(WEED_BINARY): $(TEMPL_GO_FILES) $(GO_FILES) # Auto-generate templ files when .templ files change %_templ.go: %.templ @echo "Regenerating $@ from $<" - @templ generate - -.PHONY: $(TEMPL_GO_FILES) \ No newline at end of file + @templ generate diff --git a/weed/admin/dash/auth_middleware.go b/weed/admin/dash/auth_middleware.go index 87da65659..5da81481a 100644 --- a/weed/admin/dash/auth_middleware.go +++ b/weed/admin/dash/auth_middleware.go @@ -1,6 +1,7 @@ package dash import ( + "crypto/subtle" "net/http" "github.com/gin-contrib/sessions" @@ -25,17 +26,31 @@ func (s *AdminServer) ShowLogin(c *gin.Context) { } // HandleLogin handles login form submission -func (s *AdminServer) HandleLogin(username, password string) gin.HandlerFunc { +func (s *AdminServer) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc { return func(c *gin.Context) { loginUsername := c.PostForm("username") loginPassword := c.PostForm("password") - if loginUsername == username && loginPassword == password { + var role string + var authenticated bool + + // Check admin credentials + if adminPassword != "" && loginUsername == adminUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(adminPassword)) == 1 { + role = "admin" + authenticated = true + } else if readOnlyPassword != "" && loginUsername == readOnlyUser && subtle.ConstantTimeCompare([]byte(loginPassword), []byte(readOnlyPassword)) == 1 { + // Check read-only credentials + role = "readonly" + authenticated = true + } + + if authenticated { session := sessions.Default(c) // Clear any existing invalid session data before setting new values session.Clear() session.Set("authenticated", true) session.Set("username", loginUsername) + session.Set("role", role) if err := session.Save(); err != nil { // Log the detailed error server-side for diagnostics glog.Errorf("Failed to save session for user %s: %v", loginUsername, err) diff --git a/weed/admin/dash/middleware.go b/weed/admin/dash/middleware.go index a4cfedfd0..98f3c3d7f 100644 --- a/weed/admin/dash/middleware.go +++ b/weed/admin/dash/middleware.go @@ -2,17 +2,30 @@ package dash import ( "net/http" + "strings" "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" ) +// setAuthContext sets username and role in context for use in handlers +func setAuthContext(c *gin.Context, username, role interface{}) { + c.Set("username", username) + if role != nil { + c.Set("role", role) + } else { + // Default to admin for backward compatibility + c.Set("role", "admin") + } +} + // RequireAuth checks if user is authenticated func RequireAuth() gin.HandlerFunc { return func(c *gin.Context) { session := sessions.Default(c) authenticated := session.Get("authenticated") username := session.Get("username") + role := session.Get("role") if authenticated != true || username == nil { c.Redirect(http.StatusTemporaryRedirect, "/login") @@ -20,8 +33,8 @@ func RequireAuth() gin.HandlerFunc { return } - // Set username in context for use in handlers - c.Set("username", username) + // Set username and role in context for use in handlers + setAuthContext(c, username, role) c.Next() } } @@ -33,6 +46,7 @@ func RequireAuthAPI() gin.HandlerFunc { session := sessions.Default(c) authenticated := session.Get("authenticated") username := session.Get("username") + role := session.Get("role") if authenticated != true || username == nil { c.JSON(http.StatusUnauthorized, gin.H{ @@ -43,8 +57,37 @@ func RequireAuthAPI() gin.HandlerFunc { return } - // Set username in context for use in handlers - c.Set("username", username) + // Set username and role in context for use in handlers + setAuthContext(c, username, role) + c.Next() + } +} + +// RequireWriteAccess checks if user has admin role (write access) +// Returns JSON error for API endpoints, redirects for HTML endpoints +func RequireWriteAccess() gin.HandlerFunc { + return func(c *gin.Context) { + role, exists := c.Get("role") + if !exists { + role = "admin" // Default for backward compatibility + } + + roleStr, ok := role.(string) + if !ok || roleStr != "admin" { + // Check if this is an API request (path starts with /api) or HTML request + path := c.Request.URL.Path + if strings.HasPrefix(path, "/api") { + c.JSON(http.StatusForbidden, gin.H{ + "error": "Insufficient permissions", + "message": "This operation requires admin access. Read-only users can only view data.", + }) + } else { + c.Redirect(http.StatusSeeOther, "/admin?error=Insufficient permissions") + } + c.Abort() + return + } + c.Next() } } diff --git a/weed/admin/handlers/admin_handlers.go b/weed/admin/handlers/admin_handlers.go index 31fa08113..5bf4c6a5e 100644 --- a/weed/admin/handlers/admin_handlers.go +++ b/weed/admin/handlers/admin_handlers.go @@ -5,9 +5,11 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/app" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" + "github.com/seaweedfs/seaweedfs/weed/stats" ) // AdminHandlers contains all the HTTP handlers for the admin interface @@ -44,10 +46,13 @@ func NewAdminHandlers(adminServer *dash.AdminServer) *AdminHandlers { } // SetupRoutes configures all the routes for the admin interface -func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, password string) { +func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, adminUser, adminPassword, readOnlyUser, readOnlyPassword string) { // Health check (no auth required) r.GET("/health", h.HealthCheck) + // Prometheus metrics endpoint (no auth required) + r.GET("/metrics", gin.WrapH(promhttp.HandlerFor(stats.Gather, promhttp.HandlerOpts{}))) + // Favicon route (no auth required) - redirect to static version r.GET("/favicon.ico", func(c *gin.Context) { c.Redirect(http.StatusMovedPermanently, "/static/favicon.ico") @@ -56,7 +61,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, if authRequired { // Authentication routes (no auth required) r.GET("/login", h.authHandlers.ShowLogin) - r.POST("/login", h.authHandlers.HandleLogin(username, password)) + r.POST("/login", h.authHandlers.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)) r.GET("/logout", h.authHandlers.HandleLogout) // Protected routes group @@ -96,9 +101,9 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, protected.GET("/maintenance", h.maintenanceHandlers.ShowMaintenanceQueue) protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers) protected.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig) - protected.POST("/maintenance/config", h.maintenanceHandlers.UpdateMaintenanceConfig) + protected.POST("/maintenance/config", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateMaintenanceConfig) protected.GET("/maintenance/config/:taskType", h.maintenanceHandlers.ShowTaskConfig) - protected.POST("/maintenance/config/:taskType", h.maintenanceHandlers.UpdateTaskConfig) + protected.POST("/maintenance/config/:taskType", dash.RequireWriteAccess(), h.maintenanceHandlers.UpdateTaskConfig) protected.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail) // API routes for AJAX calls @@ -115,45 +120,45 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, s3Api := api.Group("/s3") { s3Api.GET("/buckets", h.adminServer.ListBucketsAPI) - s3Api.POST("/buckets", h.adminServer.CreateBucket) - s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket) + s3Api.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateBucket) + s3Api.DELETE("/buckets/:bucket", dash.RequireWriteAccess(), h.adminServer.DeleteBucket) s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails) - s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota) - s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner) + s3Api.PUT("/buckets/:bucket/quota", dash.RequireWriteAccess(), h.adminServer.UpdateBucketQuota) + s3Api.PUT("/buckets/:bucket/owner", dash.RequireWriteAccess(), h.adminServer.UpdateBucketOwner) } // User management API routes usersApi := api.Group("/users") { usersApi.GET("", h.userHandlers.GetUsers) - usersApi.POST("", h.userHandlers.CreateUser) + usersApi.POST("", dash.RequireWriteAccess(), h.userHandlers.CreateUser) usersApi.GET("/:username", h.userHandlers.GetUserDetails) - usersApi.PUT("/:username", h.userHandlers.UpdateUser) - usersApi.DELETE("/:username", h.userHandlers.DeleteUser) - usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey) - usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey) + usersApi.PUT("/:username", dash.RequireWriteAccess(), h.userHandlers.UpdateUser) + usersApi.DELETE("/:username", dash.RequireWriteAccess(), h.userHandlers.DeleteUser) + usersApi.POST("/:username/access-keys", dash.RequireWriteAccess(), h.userHandlers.CreateAccessKey) + usersApi.DELETE("/:username/access-keys/:accessKeyId", dash.RequireWriteAccess(), h.userHandlers.DeleteAccessKey) usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies) - usersApi.PUT("/:username/policies", h.userHandlers.UpdateUserPolicies) + usersApi.PUT("/:username/policies", dash.RequireWriteAccess(), h.userHandlers.UpdateUserPolicies) } // Object Store Policy management API routes objectStorePoliciesApi := api.Group("/object-store/policies") { objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies) - objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy) + objectStorePoliciesApi.POST("", dash.RequireWriteAccess(), h.policyHandlers.CreatePolicy) objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy) - objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy) - objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy) + objectStorePoliciesApi.PUT("/:name", dash.RequireWriteAccess(), h.policyHandlers.UpdatePolicy) + objectStorePoliciesApi.DELETE("/:name", dash.RequireWriteAccess(), h.policyHandlers.DeletePolicy) objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy) } // File management API routes filesApi := api.Group("/files") { - filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile) - filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles) - filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder) - filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile) + filesApi.DELETE("/delete", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteFile) + filesApi.DELETE("/delete-multiple", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteMultipleFiles) + filesApi.POST("/create-folder", dash.RequireWriteAccess(), h.fileBrowserHandlers.CreateFolder) + filesApi.POST("/upload", dash.RequireWriteAccess(), h.fileBrowserHandlers.UploadFile) filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile) filesApi.GET("/view", h.fileBrowserHandlers.ViewFile) filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties) @@ -162,32 +167,32 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username, // Volume management API routes volumeApi := api.Group("/volumes") { - volumeApi.POST("/:id/:server/vacuum", h.clusterHandlers.VacuumVolume) + volumeApi.POST("/:id/:server/vacuum", dash.RequireWriteAccess(), h.clusterHandlers.VacuumVolume) } // Maintenance API routes maintenanceApi := api.Group("/maintenance") { - maintenanceApi.POST("/scan", h.adminServer.TriggerMaintenanceScan) + maintenanceApi.POST("/scan", dash.RequireWriteAccess(), h.adminServer.TriggerMaintenanceScan) maintenanceApi.GET("/tasks", h.adminServer.GetMaintenanceTasks) maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask) maintenanceApi.GET("/tasks/:id/detail", h.adminServer.GetMaintenanceTaskDetailAPI) - maintenanceApi.POST("/tasks/:id/cancel", h.adminServer.CancelMaintenanceTask) + maintenanceApi.POST("/tasks/:id/cancel", dash.RequireWriteAccess(), h.adminServer.CancelMaintenanceTask) maintenanceApi.GET("/workers", h.adminServer.GetMaintenanceWorkersAPI) maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker) maintenanceApi.GET("/workers/:id/logs", h.adminServer.GetWorkerLogs) maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats) maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI) - maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI) + maintenanceApi.PUT("/config", dash.RequireWriteAccess(), h.adminServer.UpdateMaintenanceConfigAPI) } // Message Queue API routes mqApi := api.Group("/mq") { mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI) - mqApi.POST("/topics/create", h.mqHandlers.CreateTopicAPI) - mqApi.POST("/topics/retention/update", h.mqHandlers.UpdateTopicRetentionAPI) - mqApi.POST("/retention/purge", h.adminServer.TriggerTopicRetentionPurgeAPI) + mqApi.POST("/topics/create", dash.RequireWriteAccess(), h.mqHandlers.CreateTopicAPI) + mqApi.POST("/topics/retention/update", dash.RequireWriteAccess(), h.mqHandlers.UpdateTopicRetentionAPI) + mqApi.POST("/retention/purge", dash.RequireWriteAccess(), h.adminServer.TriggerTopicRetentionPurgeAPI) } } } else { diff --git a/weed/admin/handlers/auth_handlers.go b/weed/admin/handlers/auth_handlers.go index 07596b8e4..ff6f6250e 100644 --- a/weed/admin/handlers/auth_handlers.go +++ b/weed/admin/handlers/auth_handlers.go @@ -3,6 +3,7 @@ package handlers import ( "net/http" + "github.com/gin-contrib/sessions" "github.com/gin-gonic/gin" "github.com/seaweedfs/seaweedfs/weed/admin/dash" "github.com/seaweedfs/seaweedfs/weed/admin/view/layout" @@ -22,6 +23,14 @@ func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers { // ShowLogin displays the login page func (a *AuthHandlers) ShowLogin(c *gin.Context) { + session := sessions.Default(c) + + // If already authenticated, redirect to admin + if session.Get("authenticated") == true { + c.Redirect(http.StatusSeeOther, "/admin") + return + } + errorMessage := c.Query("error") // Render login template @@ -35,8 +44,8 @@ func (a *AuthHandlers) ShowLogin(c *gin.Context) { } // HandleLogin handles login form submission -func (a *AuthHandlers) HandleLogin(username, password string) gin.HandlerFunc { - return a.adminServer.HandleLogin(username, password) +func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc { + return a.adminServer.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword) } // HandleLogout handles user logout diff --git a/weed/command/admin.go b/weed/command/admin.go index c8a4a4b12..f07b76780 100644 --- a/weed/command/admin.go +++ b/weed/command/admin.go @@ -33,13 +33,15 @@ var ( ) type AdminOptions struct { - port *int - grpcPort *int - master *string - masters *string // deprecated, for backward compatibility - adminUser *string - adminPassword *string - dataDir *string + port *int + grpcPort *int + master *string + masters *string // deprecated, for backward compatibility + adminUser *string + adminPassword *string + readOnlyUser *string + readOnlyPassword *string + dataDir *string } func init() { @@ -52,6 +54,8 @@ func init() { a.adminUser = cmdAdmin.Flag.String("adminUser", "admin", "admin interface username") a.adminPassword = cmdAdmin.Flag.String("adminPassword", "", "admin interface password (if empty, auth is disabled)") + a.readOnlyUser = cmdAdmin.Flag.String("readOnlyUser", "", "read-only user username (optional, for view-only access)") + a.readOnlyPassword = cmdAdmin.Flag.String("readOnlyPassword", "", "read-only user password (optional, for view-only access; requires adminPassword to be set)") } var cmdAdmin = &Command{ @@ -84,7 +88,11 @@ var cmdAdmin = &Command{ Authentication: - If adminPassword is not set, the admin interface runs without authentication - - If adminPassword is set, users must login with adminUser/adminPassword + - If adminPassword is set, users must login with adminUser/adminPassword (full access) + - Optional read-only access: set readOnlyUser and readOnlyPassword for view-only access + - Read-only users can view cluster status and configurations but cannot make changes + - IMPORTANT: When read-only credentials are configured, adminPassword MUST also be set + - This ensures an admin account exists to manage and authorize read-only access - Sessions are secured with auto-generated session keys Security Configuration: @@ -139,6 +147,26 @@ func runAdmin(cmd *Command, args []string) bool { return false } + // Security validation: prevent empty username when password is set + if *a.adminPassword != "" && *a.adminUser == "" { + fmt.Println("Error: -adminUser cannot be empty when -adminPassword is set") + return false + } + if *a.readOnlyPassword != "" && *a.readOnlyUser == "" { + fmt.Println("Error: -readOnlyUser is required when -readOnlyPassword is set") + return false + } + // Security validation: prevent username conflicts between admin and read-only users + if *a.adminUser != "" && *a.readOnlyUser != "" && *a.adminUser == *a.readOnlyUser { + fmt.Println("Error: -adminUser and -readOnlyUser must be different when both are configured") + return false + } + // Security validation: admin password is required for read-only user + if *a.readOnlyPassword != "" && *a.adminPassword == "" { + fmt.Println("Error: -adminPassword must be set when -readOnlyPassword is configured") + return false + } + // Set default gRPC port if not specified if *a.grpcPort == 0 { *a.grpcPort = *a.port + 10000 @@ -160,7 +188,10 @@ func runAdmin(cmd *Command, args []string) bool { fmt.Printf("Data Directory: Not specified (configuration will be in-memory only)\n") } if *a.adminPassword != "" { - fmt.Printf("Authentication: Enabled (user: %s)\n", *a.adminUser) + fmt.Printf("Authentication: Enabled (admin user: %s)\n", *a.adminUser) + if *a.readOnlyPassword != "" { + fmt.Printf("Read-only access: Enabled (read-only user: %s)\n", *a.readOnlyUser) + } } else { fmt.Printf("Authentication: Disabled\n") } @@ -274,8 +305,9 @@ func startAdminServer(ctx context.Context, options AdminOptions) error { }() // Create handlers and setup routes + authRequired := *options.adminPassword != "" adminHandlers := handlers.NewAdminHandlers(adminServer) - adminHandlers.SetupRoutes(r, *options.adminPassword != "", *options.adminUser, *options.adminPassword) + adminHandlers.SetupRoutes(r, authRequired, *options.adminUser, *options.adminPassword, *options.readOnlyUser, *options.readOnlyPassword) // Server configuration addr := fmt.Sprintf(":%d", *options.port) diff --git a/weed/command/mini.go b/weed/command/mini.go index 17430e916..6aa30acbd 100644 --- a/weed/command/mini.go +++ b/weed/command/mini.go @@ -72,7 +72,7 @@ This command starts all components in one process (master, volume, filer, S3 gateway, WebDAV gateway, and Admin UI). All settings are optimized for small/dev use cases: -- Volume size limit: 128MB (small files) +- Volume size limit: auto configured based on disk space (64MB-1024MB) - Volume max: 0 (auto-configured based on free disk space) - Pre-stop seconds: 1 (faster shutdown) - Master peers: none (single master mode) @@ -260,6 +260,8 @@ func initMiniAdminFlags() { miniAdminOptions.dataDir = cmdMini.Flag.String("admin.dataDir", "", "directory to store admin configuration and data files") miniAdminOptions.adminUser = cmdMini.Flag.String("admin.user", "admin", "admin interface username") miniAdminOptions.adminPassword = cmdMini.Flag.String("admin.password", "", "admin interface password (if empty, auth is disabled)") + miniAdminOptions.readOnlyUser = cmdMini.Flag.String("admin.readOnlyUser", "", "read-only user username (optional, for view-only access)") + miniAdminOptions.readOnlyPassword = cmdMini.Flag.String("admin.readOnlyPassword", "", "read-only user password (optional, for view-only access; requires admin.password to be set)") } func init() { @@ -921,6 +923,23 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) { // Set admin options *miniAdminOptions.master = masterAddr + // Security validation: prevent empty username when password is set + if *miniAdminOptions.adminPassword != "" && *miniAdminOptions.adminUser == "" { + glog.Fatalf("Error: -admin.user cannot be empty when -admin.password is set") + } + if *miniAdminOptions.readOnlyPassword != "" && *miniAdminOptions.readOnlyUser == "" { + glog.Fatalf("Error: -admin.readOnlyUser is required when -admin.readOnlyPassword is set") + } + // Security validation: prevent username conflicts between admin and read-only users + if *miniAdminOptions.adminUser != "" && *miniAdminOptions.readOnlyUser != "" && + *miniAdminOptions.adminUser == *miniAdminOptions.readOnlyUser { + glog.Fatalf("Error: -admin.user and -admin.readOnlyUser must be different when both are configured") + } + // Security validation: admin password is required for read-only user + if *miniAdminOptions.readOnlyPassword != "" && *miniAdminOptions.adminPassword == "" { + glog.Fatalf("Error: -admin.password must be set when -admin.readOnlyPassword is configured") + } + // gRPC port should have been initialized by ensureAllPortsAvailableOnIP in runMini // If it's still 0, that indicates a problem with the port initialization sequence if *miniAdminOptions.grpcPort == 0 {