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
|
||||
}
|
||||
218
internal/admin/handlers_test.go
Normal file
218
internal/admin/handlers_test.go
Normal file
@@ -0,0 +1,218 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/centrifugal/centrifuge"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestNewHandler ensures NewHandler sets up routes and proxy handling correctly.
|
||||
func TestNewHandler(t *testing.T) {
|
||||
node := ¢rifuge.Node{}
|
||||
cfg := Config{HandlerPrefix: "/prefix", WebProxyAddress: "", WebPath: ""}
|
||||
handler := NewHandler(node, nil, cfg)
|
||||
require.NotNil(t, handler)
|
||||
require.NotNil(t, handler.mux)
|
||||
|
||||
// Validate init route is registered.
|
||||
req := httptest.NewRequest("GET", "/prefix/admin/init", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// TestInitHandler checks the initHandler returns correct settings.
|
||||
func TestInitHandler(t *testing.T) {
|
||||
config := Config{}
|
||||
handler := &Handler{config: config}
|
||||
req := httptest.NewRequest("GET", "/admin/init", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
handler.initHandler(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "oss", response["edition"])
|
||||
require.Equal(t, false, response["insecure"])
|
||||
require.Equal(t, false, response["authenticated"])
|
||||
}
|
||||
|
||||
func TestInitHandler_Insecure(t *testing.T) {
|
||||
config := Config{Insecure: true}
|
||||
handler := &Handler{config: config}
|
||||
req := httptest.NewRequest("GET", "/admin/init", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
handler.initHandler(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var response map[string]any
|
||||
err := json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "oss", response["edition"])
|
||||
require.Equal(t, true, response["insecure"])
|
||||
require.Equal(t, true, response["authenticated"]) // Since insecure on.
|
||||
}
|
||||
|
||||
// TestAuthHandler_NoPasswordOrSecret tests authHandler error when password or secret is missing.
|
||||
func TestAuthHandler_NoPasswordOrSecret(t *testing.T) {
|
||||
config := Config{Password: "", Secret: ""}
|
||||
handler := &Handler{config: config}
|
||||
req := httptest.NewRequest("POST", "/admin/auth", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
handler.authHandler(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
|
||||
// TestAuthHandler_ValidPassword tests authHandler token generation with valid password.
|
||||
func TestAuthHandler_ValidPassword(t *testing.T) {
|
||||
config := Config{Password: "test-password", Secret: "test-secret"}
|
||||
handler := &Handler{config: config}
|
||||
form := url.Values{}
|
||||
form.Add("password", "test-password")
|
||||
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
handler.authHandler(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
|
||||
var response map[string]string
|
||||
err := json.NewDecoder(resp.Body).Decode(&response)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, response["token"])
|
||||
}
|
||||
|
||||
// TestAuthHandler_InvalidPassword tests authHandler rejection with invalid password.
|
||||
func TestAuthHandler_InvalidPassword(t *testing.T) {
|
||||
config := Config{Password: "test-password", Secret: "test-secret"}
|
||||
handler := &Handler{config: config}
|
||||
form := url.Values{}
|
||||
form.Add("password", "wrong-password")
|
||||
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
|
||||
req.Header.Add("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
handler.authHandler(resp, req)
|
||||
require.Equal(t, http.StatusBadRequest, resp.Code)
|
||||
}
|
||||
|
||||
// TestAuthHandler_Throttled ensures the /admin/auth route throttles repeated
|
||||
// failed password attempts per client IP, without blocking a valid login from
|
||||
// another IP while an attacker is brute-forcing.
|
||||
func TestAuthHandler_Throttled(t *testing.T) {
|
||||
node := ¢rifuge.Node{}
|
||||
cfg := Config{Password: "test-password", Secret: "test-secret"}
|
||||
handler := NewHandler(node, nil, cfg)
|
||||
|
||||
post := func(ip, password string) int {
|
||||
form := url.Values{}
|
||||
form.Add("password", password)
|
||||
req := httptest.NewRequest("POST", "/admin/auth", strings.NewReader(form.Encode()))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
req.RemoteAddr = "10.0.0.1:12345" // trusted local proxy peer, so X-Real-IP is honored.
|
||||
req.Header.Set("X-Real-IP", ip)
|
||||
resp := httptest.NewRecorder()
|
||||
handler.ServeHTTP(resp, req)
|
||||
return resp.Code
|
||||
}
|
||||
|
||||
// The default limit is 10 failures per IP per window.
|
||||
for i := 0; i < 10; i++ {
|
||||
require.Equal(t, http.StatusBadRequest, post("6.6.6.6", "wrong"), "attempt %d", i)
|
||||
}
|
||||
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "wrong"))
|
||||
// The attacker cannot tell a correct guess from a throttled one.
|
||||
require.Equal(t, http.StatusTooManyRequests, post("6.6.6.6", "test-password"))
|
||||
|
||||
// A valid login from a different IP still succeeds during the attack.
|
||||
require.Equal(t, http.StatusOK, post("7.7.7.7", "test-password"))
|
||||
}
|
||||
|
||||
// TestAdminSecureTokenAuth_InsecureMode tests adminSecureTokenAuth allows request in insecure mode.
|
||||
func TestAdminSecureTokenAuth_InsecureMode(t *testing.T) {
|
||||
config := Config{Insecure: true}
|
||||
handler := &Handler{config: config}
|
||||
|
||||
// Mocked handler that should be invoked
|
||||
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
authHandler := handler.adminSecureTokenAuth(finalHandler)
|
||||
req := httptest.NewRequest("GET", "/admin/api", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
authHandler.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// TestAdminSecureTokenAuth_MissingToken tests adminSecureTokenAuth rejection when token is missing.
|
||||
func TestAdminSecureTokenAuth_MissingToken(t *testing.T) {
|
||||
config := Config{Secret: "test-secret"}
|
||||
handler := &Handler{config: config}
|
||||
|
||||
// Mocked handler that should not be invoked
|
||||
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
authHandler := handler.adminSecureTokenAuth(finalHandler)
|
||||
req := httptest.NewRequest("GET", "/admin/api", nil)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
authHandler.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||
}
|
||||
|
||||
// TestAdminSecureTokenAuth_ValidToken tests adminSecureTokenAuth with valid token.
|
||||
func TestAdminSecureTokenAuth_ValidToken(t *testing.T) {
|
||||
config := Config{Secret: "test-secret"}
|
||||
handler := &Handler{config: config}
|
||||
|
||||
// Mocked handler that should be invoked
|
||||
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
// Generate a valid token
|
||||
token, err := generateSecureAdminToken("test-secret")
|
||||
require.NoError(t, err)
|
||||
|
||||
authHandler := handler.adminSecureTokenAuth(finalHandler)
|
||||
req := httptest.NewRequest("GET", "/admin/api", nil)
|
||||
req.Header.Set("Authorization", "token "+token)
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
authHandler.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusOK, resp.Code)
|
||||
}
|
||||
|
||||
// TestAdminSecureTokenAuth_InvalidToken tests adminSecureTokenAuth rejection with invalid token.
|
||||
func TestAdminSecureTokenAuth_InvalidToken(t *testing.T) {
|
||||
config := Config{Secret: "test-secret"}
|
||||
handler := &Handler{config: config}
|
||||
|
||||
// Mocked handler that should not be invoked.
|
||||
finalHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Fail(t, "final handler should not be invoked")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
authHandler := handler.adminSecureTokenAuth(finalHandler)
|
||||
req := httptest.NewRequest("GET", "/admin/api", nil)
|
||||
req.Header.Set("Authorization", "token invalid-token")
|
||||
resp := httptest.NewRecorder()
|
||||
|
||||
authHandler.ServeHTTP(resp, req)
|
||||
require.Equal(t, http.StatusUnauthorized, resp.Code)
|
||||
}
|
||||
Reference in New Issue
Block a user