snapshot of https://github.com/centrifugal/centrifugo.git (master, 2026-09-16; blobs > 131072B omitted)
This commit is contained in:
201
internal/admin/handlers.go
Normal file
201
internal/admin/handlers.go
Normal file
@@ -0,0 +1,201 @@
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user