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 <chris.lu@gmail.com>
This commit is contained in:
@@ -160,6 +160,4 @@ $(WEED_BINARY): $(TEMPL_GO_FILES) $(GO_FILES)
|
|||||||
# Auto-generate templ files when .templ files change
|
# Auto-generate templ files when .templ files change
|
||||||
%_templ.go: %.templ
|
%_templ.go: %.templ
|
||||||
@echo "Regenerating $@ from $<"
|
@echo "Regenerating $@ from $<"
|
||||||
@templ generate
|
@templ generate
|
||||||
|
|
||||||
.PHONY: $(TEMPL_GO_FILES)
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package dash
|
package dash
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/subtle"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
@@ -25,17 +26,31 @@ func (s *AdminServer) ShowLogin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleLogin handles login form submission
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
loginUsername := c.PostForm("username")
|
loginUsername := c.PostForm("username")
|
||||||
loginPassword := c.PostForm("password")
|
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)
|
session := sessions.Default(c)
|
||||||
// Clear any existing invalid session data before setting new values
|
// Clear any existing invalid session data before setting new values
|
||||||
session.Clear()
|
session.Clear()
|
||||||
session.Set("authenticated", true)
|
session.Set("authenticated", true)
|
||||||
session.Set("username", loginUsername)
|
session.Set("username", loginUsername)
|
||||||
|
session.Set("role", role)
|
||||||
if err := session.Save(); err != nil {
|
if err := session.Save(); err != nil {
|
||||||
// Log the detailed error server-side for diagnostics
|
// Log the detailed error server-side for diagnostics
|
||||||
glog.Errorf("Failed to save session for user %s: %v", loginUsername, err)
|
glog.Errorf("Failed to save session for user %s: %v", loginUsername, err)
|
||||||
|
|||||||
@@ -2,17 +2,30 @@ package dash
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-contrib/sessions"
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"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
|
// RequireAuth checks if user is authenticated
|
||||||
func RequireAuth() gin.HandlerFunc {
|
func RequireAuth() gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
authenticated := session.Get("authenticated")
|
authenticated := session.Get("authenticated")
|
||||||
username := session.Get("username")
|
username := session.Get("username")
|
||||||
|
role := session.Get("role")
|
||||||
|
|
||||||
if authenticated != true || username == nil {
|
if authenticated != true || username == nil {
|
||||||
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
c.Redirect(http.StatusTemporaryRedirect, "/login")
|
||||||
@@ -20,8 +33,8 @@ func RequireAuth() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set username in context for use in handlers
|
// Set username and role in context for use in handlers
|
||||||
c.Set("username", username)
|
setAuthContext(c, username, role)
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -33,6 +46,7 @@ func RequireAuthAPI() gin.HandlerFunc {
|
|||||||
session := sessions.Default(c)
|
session := sessions.Default(c)
|
||||||
authenticated := session.Get("authenticated")
|
authenticated := session.Get("authenticated")
|
||||||
username := session.Get("username")
|
username := session.Get("username")
|
||||||
|
role := session.Get("role")
|
||||||
|
|
||||||
if authenticated != true || username == nil {
|
if authenticated != true || username == nil {
|
||||||
c.JSON(http.StatusUnauthorized, gin.H{
|
c.JSON(http.StatusUnauthorized, gin.H{
|
||||||
@@ -43,8 +57,37 @@ func RequireAuthAPI() gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set username in context for use in handlers
|
// Set username and role in context for use in handlers
|
||||||
c.Set("username", username)
|
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()
|
c.Next()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,9 +5,11 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"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/dash"
|
||||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/app"
|
||||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||||
|
"github.com/seaweedfs/seaweedfs/weed/stats"
|
||||||
)
|
)
|
||||||
|
|
||||||
// AdminHandlers contains all the HTTP handlers for the admin interface
|
// 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
|
// 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)
|
// Health check (no auth required)
|
||||||
r.GET("/health", h.HealthCheck)
|
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
|
// Favicon route (no auth required) - redirect to static version
|
||||||
r.GET("/favicon.ico", func(c *gin.Context) {
|
r.GET("/favicon.ico", func(c *gin.Context) {
|
||||||
c.Redirect(http.StatusMovedPermanently, "/static/favicon.ico")
|
c.Redirect(http.StatusMovedPermanently, "/static/favicon.ico")
|
||||||
@@ -56,7 +61,7 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
|||||||
if authRequired {
|
if authRequired {
|
||||||
// Authentication routes (no auth required)
|
// Authentication routes (no auth required)
|
||||||
r.GET("/login", h.authHandlers.ShowLogin)
|
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)
|
r.GET("/logout", h.authHandlers.HandleLogout)
|
||||||
|
|
||||||
// Protected routes group
|
// 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", h.maintenanceHandlers.ShowMaintenanceQueue)
|
||||||
protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
|
protected.GET("/maintenance/workers", h.maintenanceHandlers.ShowMaintenanceWorkers)
|
||||||
protected.GET("/maintenance/config", h.maintenanceHandlers.ShowMaintenanceConfig)
|
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.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)
|
protected.GET("/maintenance/tasks/:id", h.maintenanceHandlers.ShowTaskDetail)
|
||||||
|
|
||||||
// API routes for AJAX calls
|
// API routes for AJAX calls
|
||||||
@@ -115,45 +120,45 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
|||||||
s3Api := api.Group("/s3")
|
s3Api := api.Group("/s3")
|
||||||
{
|
{
|
||||||
s3Api.GET("/buckets", h.adminServer.ListBucketsAPI)
|
s3Api.GET("/buckets", h.adminServer.ListBucketsAPI)
|
||||||
s3Api.POST("/buckets", h.adminServer.CreateBucket)
|
s3Api.POST("/buckets", dash.RequireWriteAccess(), h.adminServer.CreateBucket)
|
||||||
s3Api.DELETE("/buckets/:bucket", h.adminServer.DeleteBucket)
|
s3Api.DELETE("/buckets/:bucket", dash.RequireWriteAccess(), h.adminServer.DeleteBucket)
|
||||||
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
|
s3Api.GET("/buckets/:bucket", h.adminServer.ShowBucketDetails)
|
||||||
s3Api.PUT("/buckets/:bucket/quota", h.adminServer.UpdateBucketQuota)
|
s3Api.PUT("/buckets/:bucket/quota", dash.RequireWriteAccess(), h.adminServer.UpdateBucketQuota)
|
||||||
s3Api.PUT("/buckets/:bucket/owner", h.adminServer.UpdateBucketOwner)
|
s3Api.PUT("/buckets/:bucket/owner", dash.RequireWriteAccess(), h.adminServer.UpdateBucketOwner)
|
||||||
}
|
}
|
||||||
|
|
||||||
// User management API routes
|
// User management API routes
|
||||||
usersApi := api.Group("/users")
|
usersApi := api.Group("/users")
|
||||||
{
|
{
|
||||||
usersApi.GET("", h.userHandlers.GetUsers)
|
usersApi.GET("", h.userHandlers.GetUsers)
|
||||||
usersApi.POST("", h.userHandlers.CreateUser)
|
usersApi.POST("", dash.RequireWriteAccess(), h.userHandlers.CreateUser)
|
||||||
usersApi.GET("/:username", h.userHandlers.GetUserDetails)
|
usersApi.GET("/:username", h.userHandlers.GetUserDetails)
|
||||||
usersApi.PUT("/:username", h.userHandlers.UpdateUser)
|
usersApi.PUT("/:username", dash.RequireWriteAccess(), h.userHandlers.UpdateUser)
|
||||||
usersApi.DELETE("/:username", h.userHandlers.DeleteUser)
|
usersApi.DELETE("/:username", dash.RequireWriteAccess(), h.userHandlers.DeleteUser)
|
||||||
usersApi.POST("/:username/access-keys", h.userHandlers.CreateAccessKey)
|
usersApi.POST("/:username/access-keys", dash.RequireWriteAccess(), h.userHandlers.CreateAccessKey)
|
||||||
usersApi.DELETE("/:username/access-keys/:accessKeyId", h.userHandlers.DeleteAccessKey)
|
usersApi.DELETE("/:username/access-keys/:accessKeyId", dash.RequireWriteAccess(), h.userHandlers.DeleteAccessKey)
|
||||||
usersApi.GET("/:username/policies", h.userHandlers.GetUserPolicies)
|
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
|
// Object Store Policy management API routes
|
||||||
objectStorePoliciesApi := api.Group("/object-store/policies")
|
objectStorePoliciesApi := api.Group("/object-store/policies")
|
||||||
{
|
{
|
||||||
objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
|
objectStorePoliciesApi.GET("", h.policyHandlers.GetPolicies)
|
||||||
objectStorePoliciesApi.POST("", h.policyHandlers.CreatePolicy)
|
objectStorePoliciesApi.POST("", dash.RequireWriteAccess(), h.policyHandlers.CreatePolicy)
|
||||||
objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
|
objectStorePoliciesApi.GET("/:name", h.policyHandlers.GetPolicy)
|
||||||
objectStorePoliciesApi.PUT("/:name", h.policyHandlers.UpdatePolicy)
|
objectStorePoliciesApi.PUT("/:name", dash.RequireWriteAccess(), h.policyHandlers.UpdatePolicy)
|
||||||
objectStorePoliciesApi.DELETE("/:name", h.policyHandlers.DeletePolicy)
|
objectStorePoliciesApi.DELETE("/:name", dash.RequireWriteAccess(), h.policyHandlers.DeletePolicy)
|
||||||
objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
|
objectStorePoliciesApi.POST("/validate", h.policyHandlers.ValidatePolicy)
|
||||||
}
|
}
|
||||||
|
|
||||||
// File management API routes
|
// File management API routes
|
||||||
filesApi := api.Group("/files")
|
filesApi := api.Group("/files")
|
||||||
{
|
{
|
||||||
filesApi.DELETE("/delete", h.fileBrowserHandlers.DeleteFile)
|
filesApi.DELETE("/delete", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteFile)
|
||||||
filesApi.DELETE("/delete-multiple", h.fileBrowserHandlers.DeleteMultipleFiles)
|
filesApi.DELETE("/delete-multiple", dash.RequireWriteAccess(), h.fileBrowserHandlers.DeleteMultipleFiles)
|
||||||
filesApi.POST("/create-folder", h.fileBrowserHandlers.CreateFolder)
|
filesApi.POST("/create-folder", dash.RequireWriteAccess(), h.fileBrowserHandlers.CreateFolder)
|
||||||
filesApi.POST("/upload", h.fileBrowserHandlers.UploadFile)
|
filesApi.POST("/upload", dash.RequireWriteAccess(), h.fileBrowserHandlers.UploadFile)
|
||||||
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
|
filesApi.GET("/download", h.fileBrowserHandlers.DownloadFile)
|
||||||
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
|
filesApi.GET("/view", h.fileBrowserHandlers.ViewFile)
|
||||||
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
|
filesApi.GET("/properties", h.fileBrowserHandlers.GetFileProperties)
|
||||||
@@ -162,32 +167,32 @@ func (h *AdminHandlers) SetupRoutes(r *gin.Engine, authRequired bool, username,
|
|||||||
// Volume management API routes
|
// Volume management API routes
|
||||||
volumeApi := api.Group("/volumes")
|
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
|
// Maintenance API routes
|
||||||
maintenanceApi := api.Group("/maintenance")
|
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", h.adminServer.GetMaintenanceTasks)
|
||||||
maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
|
maintenanceApi.GET("/tasks/:id", h.adminServer.GetMaintenanceTask)
|
||||||
maintenanceApi.GET("/tasks/:id/detail", h.adminServer.GetMaintenanceTaskDetailAPI)
|
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", h.adminServer.GetMaintenanceWorkersAPI)
|
||||||
maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
|
maintenanceApi.GET("/workers/:id", h.adminServer.GetMaintenanceWorker)
|
||||||
maintenanceApi.GET("/workers/:id/logs", h.adminServer.GetWorkerLogs)
|
maintenanceApi.GET("/workers/:id/logs", h.adminServer.GetWorkerLogs)
|
||||||
maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
|
maintenanceApi.GET("/stats", h.adminServer.GetMaintenanceStats)
|
||||||
maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
|
maintenanceApi.GET("/config", h.adminServer.GetMaintenanceConfigAPI)
|
||||||
maintenanceApi.PUT("/config", h.adminServer.UpdateMaintenanceConfigAPI)
|
maintenanceApi.PUT("/config", dash.RequireWriteAccess(), h.adminServer.UpdateMaintenanceConfigAPI)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Message Queue API routes
|
// Message Queue API routes
|
||||||
mqApi := api.Group("/mq")
|
mqApi := api.Group("/mq")
|
||||||
{
|
{
|
||||||
mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI)
|
mqApi.GET("/topics/:namespace/:topic", h.mqHandlers.GetTopicDetailsAPI)
|
||||||
mqApi.POST("/topics/create", h.mqHandlers.CreateTopicAPI)
|
mqApi.POST("/topics/create", dash.RequireWriteAccess(), h.mqHandlers.CreateTopicAPI)
|
||||||
mqApi.POST("/topics/retention/update", h.mqHandlers.UpdateTopicRetentionAPI)
|
mqApi.POST("/topics/retention/update", dash.RequireWriteAccess(), h.mqHandlers.UpdateTopicRetentionAPI)
|
||||||
mqApi.POST("/retention/purge", h.adminServer.TriggerTopicRetentionPurgeAPI)
|
mqApi.POST("/retention/purge", dash.RequireWriteAccess(), h.adminServer.TriggerTopicRetentionPurgeAPI)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-contrib/sessions"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
"github.com/seaweedfs/seaweedfs/weed/admin/dash"
|
||||||
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
"github.com/seaweedfs/seaweedfs/weed/admin/view/layout"
|
||||||
@@ -22,6 +23,14 @@ func NewAuthHandlers(adminServer *dash.AdminServer) *AuthHandlers {
|
|||||||
|
|
||||||
// ShowLogin displays the login page
|
// ShowLogin displays the login page
|
||||||
func (a *AuthHandlers) ShowLogin(c *gin.Context) {
|
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")
|
errorMessage := c.Query("error")
|
||||||
|
|
||||||
// Render login template
|
// Render login template
|
||||||
@@ -35,8 +44,8 @@ func (a *AuthHandlers) ShowLogin(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// HandleLogin handles login form submission
|
// HandleLogin handles login form submission
|
||||||
func (a *AuthHandlers) HandleLogin(username, password string) gin.HandlerFunc {
|
func (a *AuthHandlers) HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword string) gin.HandlerFunc {
|
||||||
return a.adminServer.HandleLogin(username, password)
|
return a.adminServer.HandleLogin(adminUser, adminPassword, readOnlyUser, readOnlyPassword)
|
||||||
}
|
}
|
||||||
|
|
||||||
// HandleLogout handles user logout
|
// HandleLogout handles user logout
|
||||||
|
|||||||
@@ -33,13 +33,15 @@ var (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type AdminOptions struct {
|
type AdminOptions struct {
|
||||||
port *int
|
port *int
|
||||||
grpcPort *int
|
grpcPort *int
|
||||||
master *string
|
master *string
|
||||||
masters *string // deprecated, for backward compatibility
|
masters *string // deprecated, for backward compatibility
|
||||||
adminUser *string
|
adminUser *string
|
||||||
adminPassword *string
|
adminPassword *string
|
||||||
dataDir *string
|
readOnlyUser *string
|
||||||
|
readOnlyPassword *string
|
||||||
|
dataDir *string
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
func init() {
|
||||||
@@ -52,6 +54,8 @@ func init() {
|
|||||||
|
|
||||||
a.adminUser = cmdAdmin.Flag.String("adminUser", "admin", "admin interface username")
|
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.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{
|
var cmdAdmin = &Command{
|
||||||
@@ -84,7 +88,11 @@ var cmdAdmin = &Command{
|
|||||||
|
|
||||||
Authentication:
|
Authentication:
|
||||||
- If adminPassword is not set, the admin interface runs without 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
|
- Sessions are secured with auto-generated session keys
|
||||||
|
|
||||||
Security Configuration:
|
Security Configuration:
|
||||||
@@ -139,6 +147,26 @@ func runAdmin(cmd *Command, args []string) bool {
|
|||||||
return false
|
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
|
// Set default gRPC port if not specified
|
||||||
if *a.grpcPort == 0 {
|
if *a.grpcPort == 0 {
|
||||||
*a.grpcPort = *a.port + 10000
|
*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")
|
fmt.Printf("Data Directory: Not specified (configuration will be in-memory only)\n")
|
||||||
}
|
}
|
||||||
if *a.adminPassword != "" {
|
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 {
|
} else {
|
||||||
fmt.Printf("Authentication: Disabled\n")
|
fmt.Printf("Authentication: Disabled\n")
|
||||||
}
|
}
|
||||||
@@ -274,8 +305,9 @@ func startAdminServer(ctx context.Context, options AdminOptions) error {
|
|||||||
}()
|
}()
|
||||||
|
|
||||||
// Create handlers and setup routes
|
// Create handlers and setup routes
|
||||||
|
authRequired := *options.adminPassword != ""
|
||||||
adminHandlers := handlers.NewAdminHandlers(adminServer)
|
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
|
// Server configuration
|
||||||
addr := fmt.Sprintf(":%d", *options.port)
|
addr := fmt.Sprintf(":%d", *options.port)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ This command starts all components in one process (master, volume, filer,
|
|||||||
S3 gateway, WebDAV gateway, and Admin UI).
|
S3 gateway, WebDAV gateway, and Admin UI).
|
||||||
|
|
||||||
All settings are optimized for small/dev use cases:
|
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)
|
- Volume max: 0 (auto-configured based on free disk space)
|
||||||
- Pre-stop seconds: 1 (faster shutdown)
|
- Pre-stop seconds: 1 (faster shutdown)
|
||||||
- Master peers: none (single master mode)
|
- 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.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.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.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() {
|
func init() {
|
||||||
@@ -921,6 +923,23 @@ func startMiniAdminWithWorker(allServicesReady chan struct{}) {
|
|||||||
// Set admin options
|
// Set admin options
|
||||||
*miniAdminOptions.master = masterAddr
|
*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
|
// 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 it's still 0, that indicates a problem with the port initialization sequence
|
||||||
if *miniAdminOptions.grpcPort == 0 {
|
if *miniAdminOptions.grpcPort == 0 {
|
||||||
|
|||||||
Reference in New Issue
Block a user