package admin import ( "encoding/json" "net/http" "strings" "time" "github.com/centrifugal/centrifugo/v6/internal/api" "github.com/centrifugal/centrifugo/v6/internal/configtypes" "github.com/centrifugal/centrifugo/v6/internal/middleware" "github.com/centrifugal/centrifugo/v6/internal/reverseproxy" "github.com/centrifugal/centrifugo/v6/internal/tools" "github.com/centrifugal/centrifugo/v6/internal/webui" "github.com/centrifugal/centrifuge" "github.com/gorilla/securecookie" "github.com/rs/zerolog/log" ) type Config = configtypes.Admin // Handler handles admin web UI endpoints. type Handler struct { mux *http.ServeMux node *centrifuge.Node config configtypes.Admin } // NewHandler creates new Handler. func NewHandler(n *centrifuge.Node, apiExecutor *api.Executor, c Config) *Handler { h := &Handler{ node: n, config: c, } mux := http.NewServeMux() prefix := strings.TrimRight(h.config.HandlerPrefix, "/") mux.Handle(prefix+"/admin/init", http.HandlerFunc(h.initHandler)) // Throttle repeated failed password attempts per client IP to slow brute-force // of admin.password. Only failures count and each IP is tracked independently, // so a valid login is never throttled - including while another source attacks. // // This is best-effort, in-process protection only. The admin auth endpoint is // intended to be reachable by trusted operators, and Centrifugo recommends // restricting access to it at the infrastructure level (firewall rules, a // private network, an authenticating reverse proxy, etc.). The throttle here // raises the bar for brute-force but is not a substitute for that: the primary // protection of the admin endpoint must be done at the infrastructure level. authThrottle := middleware.NewAuthThrottle(10, time.Minute, nil) mux.Handle(prefix+"/admin/auth", middleware.Post(authThrottle.Middleware(http.HandlerFunc(h.authHandler)))) mux.Handle(prefix+"/admin/api", middleware.Post(h.adminSecureTokenAuth(api.NewHandler(n, apiExecutor, api.Config{}).OldRoute()))) webPrefix := prefix + "/" if c.WebProxyAddress != "" { log.Info().Str("address", c.WebProxyAddress).Msg("using admin web reverse proxy") proxy, err := reverseproxy.New(c.WebProxyAddress) if err != nil { log.Fatal().Err(err).Msg("error creating admin web reverse proxy") } mux.HandleFunc(webPrefix, reverseproxy.ProxyRequestHandler(proxy)) } else if c.WebPath != "" { mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(http.Dir(c.WebPath)))) } else { mux.Handle(webPrefix, http.StripPrefix(webPrefix, http.FileServer(webui.FS))) } h.mux = mux return h } func (s *Handler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { s.mux.ServeHTTP(rw, r) } func (s *Handler) adminSecureTokenAuth(h http.Handler) http.Handler { secret := s.config.Secret insecure := s.config.Insecure return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if insecure { h.ServeHTTP(w, r) return } if secret == "" { log.Error().Msg("no admin secret key found in configuration") w.WriteHeader(http.StatusUnauthorized) return } var token string authorization := r.Header.Get("Authorization") if authorization != "" { parts := strings.Fields(authorization) if len(parts) != 2 { w.WriteHeader(http.StatusUnauthorized) return } authMethod := strings.ToLower(parts[0]) if authMethod != "token" { w.WriteHeader(http.StatusUnauthorized) return } token = parts[1] } if token == "" || !checkSecureAdminToken(secret, token) { w.WriteHeader(http.StatusUnauthorized) return } h.ServeHTTP(w, r) }) } // initHandler allows to get admin web interface settings. func (s *Handler) initHandler(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json") resp := map[string]any{ "insecure": s.config.Insecure, "edition": "oss", } secret := s.config.Secret authenticated := false if s.config.Insecure { authenticated = true } else if secret != "" { var token string authorization := r.Header.Get("Authorization") if authorization != "" { parts := strings.Fields(authorization) if len(parts) == 2 && strings.ToLower(parts[0]) == "token" { token = parts[1] } } if token != "" && checkSecureAdminToken(secret, token) { authenticated = true } } resp["authenticated"] = authenticated _ = json.NewEncoder(w).Encode(resp) } // authHandler allows to get admin web interface token. func (s *Handler) authHandler(w http.ResponseWriter, r *http.Request) { r.Body = http.MaxBytesReader(w, r.Body, 1024*1024) // 1MB limit. formPassword := r.FormValue("password") password := s.config.Password secret := s.config.Secret if password == "" || secret == "" { log.Error().Msg("admin_password and admin_secret must be set in configuration") http.Error(w, "Bad Request", http.StatusBadRequest) return } if tools.SecureCompareString(formPassword, password) { w.Header().Set("Content-Type", "application/json") token, err := generateSecureAdminToken(secret) if err != nil { log.Error().Msgf("error generating admin token: %v", err) http.Error(w, "Internal Server Error", http.StatusInternalServerError) return } resp := map[string]string{ "token": token, } _ = json.NewEncoder(w).Encode(resp) return } http.Error(w, "Bad Request", http.StatusBadRequest) } const ( // AdminTokenKey is a key for admin authorization token. secureAdminTokenKey = "token" // AdminTokenValue is a value for secure admin authorization token. secureAdminTokenValue = "authorized" ) // generateSecureAdminToken generates admin authentication token. func generateSecureAdminToken(secret string) (string, error) { s := securecookie.New([]byte(secret), nil) return s.Encode(secureAdminTokenKey, secureAdminTokenValue) } // checkSecureAdminToken checks admin connection token which Centrifugo returns after admin login. func checkSecureAdminToken(secret string, token string) bool { s := securecookie.New([]byte(secret), nil) var val string err := s.Decode(secureAdminTokenKey, token, &val) if err != nil { return false } if val != secureAdminTokenValue { return false } return true }