- lighthouse: Registry of namespace authorizers (Result, Allowed, Denied), ParseChannel, ChannelID with PHP (int)-cast semantics (PHPInt, pinned by a php -r table test), FormatChannels, WithClientID/ClientID - centrifugo: ProxyHandler (constant-time X-Centrifugo-Secret, HTTP 200 generic deny, info [] on allow, presence allow/override merge, 64 KiB body cap) mounted as the ServerToServer subscribe route - README: proxy contract, registry and channel rules
306 lines
8.9 KiB
Go
306 lines
8.9 KiB
Go
package centrifugo
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/subtle"
|
|
"encoding/json"
|
|
"log/slog"
|
|
"net/http"
|
|
"sort"
|
|
|
|
"git.golem15.com/golem15/summercms/modules/bouncer"
|
|
"git.golem15.com/golem15/summercms/modules/lighthouse"
|
|
"git.golem15.com/golem15/summercms/modules/surf"
|
|
)
|
|
|
|
type errorBody struct {
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
type tokenBody struct {
|
|
Token string `json:"token"`
|
|
}
|
|
|
|
// TokenHandler issues the connection token of the signed-in user. It must
|
|
// be mounted behind a user guard (the UserAuth surface).
|
|
//
|
|
// - no principal, or no user for it: 401 {"error":"Unauthorized"}
|
|
// - no token secret: 503 with the WinterCMS not-configured error body
|
|
// - otherwise 200 {"token":"…"} (see TokenIssuer.ForUser)
|
|
func TokenHandler(svc *lighthouse.Service, issuer *TokenIssuer) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
p, ok := bouncer.User(r.Context())
|
|
if !ok || p == nil || p.ID == 0 {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody{Error: "Unauthorized"})
|
|
return
|
|
}
|
|
u, found, err := svc.User(r.Context(), p.ID)
|
|
if err != nil || !found {
|
|
writeJSON(w, http.StatusUnauthorized, errorBody{Error: "Unauthorized"})
|
|
return
|
|
}
|
|
if !issuer.Configured() {
|
|
writeJSON(w, http.StatusServiceUnavailable, errorBody{Error: "WebSocket not configured"})
|
|
return
|
|
}
|
|
tok, err := issuer.ForUser(u)
|
|
if err != nil {
|
|
svc.Logger().Error("realtime: token signing failed", "error", err)
|
|
writeJSON(w, http.StatusInternalServerError, errorBody{Error: "Internal server error"})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, tokenBody{Token: tok})
|
|
}
|
|
}
|
|
|
|
// writeJSON writes v with no trailing newline and no HTML escaping, plus the
|
|
// Content-Type and Cache-Control headers of a Laravel JSON response.
|
|
func writeJSON(w http.ResponseWriter, status int, v any) {
|
|
var buf bytes.Buffer
|
|
enc := json.NewEncoder(&buf)
|
|
enc.SetEscapeHTML(false)
|
|
if err := enc.Encode(v); err != nil {
|
|
w.WriteHeader(http.StatusInternalServerError)
|
|
return
|
|
}
|
|
h := w.Header()
|
|
h.Set("Content-Type", "application/json")
|
|
h.Set("Cache-Control", "no-cache, private")
|
|
w.WriteHeader(status)
|
|
_, _ = w.Write(bytes.TrimSuffix(buf.Bytes(), []byte("\n")))
|
|
}
|
|
|
|
// maxProxyBody caps a subscribe proxy request body.
|
|
const maxProxyBody = 64 << 10
|
|
|
|
// denyAnswer is the one answer every denied subscribe gets. Centrifugo
|
|
// reads a non-200 status as internal error 100, so a deny is HTTP 200 with
|
|
// an error body; it never reveals why.
|
|
var denyAnswer = json.RawMessage(`{"error":{"code":403,"message":"` + accessDenied + `"}}`)
|
|
|
|
// accessDenied is the generic deny message, also logged when an authorizer
|
|
// gives no reason.
|
|
const accessDenied = "Access denied"
|
|
|
|
// proxyRequest is the part of Centrifugo's subscribe proxy body the
|
|
// handler reads.
|
|
type proxyRequest struct {
|
|
User json.RawMessage `json:"user"`
|
|
Channel *string `json:"channel"`
|
|
Client string `json:"client"`
|
|
}
|
|
|
|
// ProxyHandler is the Centrifugo subscribe proxy endpoint, the WinterCMS
|
|
// ProxyController::subscribe. Every subscribe is re-authorized:
|
|
//
|
|
// 1. X-Centrifugo-Secret must equal cfg.ProxySecret (constant-time; an
|
|
// empty configured secret denies everything);
|
|
// 2. an empty or "0" user denies;
|
|
// 3. the namespace comes from lighthouse.ParseChannel and must have a
|
|
// registered authorizer;
|
|
// 4. the authorizer gets the user id (PHP (int) cast, negative as 0) and
|
|
// the full original channel, with the client id in ctx.
|
|
//
|
|
// An allow answers {"result":{"info":…}} with info [] when empty; a
|
|
// presence channel also gets "allow" (the authorizer's capabilities or
|
|
// ["prs"]) and "override" (presence and join_leave true,
|
|
// force_push_join_leave false, then the authorizer's overrides). Every
|
|
// deny answers the same HTTP 200 body and logs the reason at Warn.
|
|
func ProxyHandler(svc *lighthouse.Service, cfg Config) http.HandlerFunc {
|
|
return func(w http.ResponseWriter, r *http.Request) {
|
|
log := svc.Logger()
|
|
provided := r.Header.Get("X-Centrifugo-Secret")
|
|
if cfg.ProxySecret == "" || subtle.ConstantTimeCompare([]byte(cfg.ProxySecret), []byte(provided)) != 1 {
|
|
deny(w, log, "Invalid or missing proxy secret", slog.String("ip", surf.ClientIP(r, cfg.TrustedProxies)))
|
|
return
|
|
}
|
|
var req proxyRequest
|
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxProxyBody))
|
|
dec.UseNumber()
|
|
if err := dec.Decode(&req); err != nil {
|
|
deny(w, log, "Malformed proxy request", slog.String("ip", surf.ClientIP(r, cfg.TrustedProxies)))
|
|
return
|
|
}
|
|
user := userInput(req.User)
|
|
channel := ""
|
|
if req.Channel != nil {
|
|
channel = *req.Channel
|
|
}
|
|
if user == "" || user == "0" {
|
|
deny(w, log, "Authentication required", slog.String("channel", channel), slog.String("client", req.Client))
|
|
return
|
|
}
|
|
if req.Channel == nil || channel == "" {
|
|
// Centrifugo always sends a channel; WinterCMS would fail with
|
|
// a TypeError here. Denying is deliberate hardening.
|
|
deny(w, log, "Missing channel", slog.String("user", user), slog.String("client", req.Client))
|
|
return
|
|
}
|
|
namespace, presence := lighthouse.ParseChannel(channel)
|
|
authorizer, ok := svc.Registry().Get(namespace)
|
|
if !ok {
|
|
deny(w, log, "Unknown channel namespace",
|
|
slog.String("namespace", namespace), slog.String("channel", channel),
|
|
slog.String("user", user), slog.String("client", req.Client))
|
|
return
|
|
}
|
|
id := lighthouse.PHPInt(user)
|
|
if id < 0 {
|
|
id = 0
|
|
}
|
|
ctx := lighthouse.WithClientID(r.Context(), req.Client)
|
|
res := authorizer.Authorize(ctx, uint(id), channel)
|
|
if !res.Allowed {
|
|
reason := res.Reason()
|
|
if reason == "" {
|
|
reason = accessDenied
|
|
}
|
|
deny(w, log, reason,
|
|
slog.String("user", user), slog.String("channel", channel),
|
|
slog.String("client", req.Client), slog.String("internal_reason", res.Reason()))
|
|
return
|
|
}
|
|
allow(w, res, presence)
|
|
}
|
|
}
|
|
|
|
// userInput returns the user field as PHP's $request->input('user') string
|
|
// form: a JSON string as is, a JSON number as its literal. Anything else is
|
|
// "" (treated as empty).
|
|
func userInput(raw json.RawMessage) string {
|
|
raw = bytes.TrimSpace(raw)
|
|
if len(raw) == 0 {
|
|
return ""
|
|
}
|
|
switch raw[0] {
|
|
case '"':
|
|
var s string
|
|
if err := json.Unmarshal(raw, &s); err != nil {
|
|
return ""
|
|
}
|
|
return s
|
|
case '-', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9':
|
|
var n json.Number
|
|
if err := json.Unmarshal(raw, &n); err != nil {
|
|
return ""
|
|
}
|
|
if f, err := n.Float64(); err == nil && f == 0 {
|
|
return "0" // PHP empty(0) and empty(0.0)
|
|
}
|
|
return n.String()
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func deny(w http.ResponseWriter, log *slog.Logger, reason string, attrs ...slog.Attr) {
|
|
args := make([]any, 0, len(attrs)+1)
|
|
args = append(args, slog.String("reason", reason))
|
|
for _, a := range attrs {
|
|
args = append(args, a)
|
|
}
|
|
log.Warn("Subscription denied", args...)
|
|
writeRaw(w, denyAnswer)
|
|
}
|
|
|
|
func allow(w http.ResponseWriter, res lighthouse.Result, presence bool) {
|
|
var buf bytes.Buffer
|
|
buf.WriteString(`{"result":{"info":`)
|
|
if len(res.Info) == 0 {
|
|
buf.WriteString(`[]`)
|
|
} else if b, err := marshal(res.Info); err == nil {
|
|
buf.Write(b)
|
|
} else {
|
|
writeRaw(w, denyAnswer)
|
|
return
|
|
}
|
|
if presence {
|
|
caps := res.Capabilities
|
|
if caps == nil {
|
|
caps = []string{"prs"}
|
|
}
|
|
b, err := marshal(caps)
|
|
if err != nil {
|
|
writeRaw(w, denyAnswer)
|
|
return
|
|
}
|
|
buf.WriteString(`,"allow":`)
|
|
buf.Write(b)
|
|
o, err := overrideJSON(res.Overrides)
|
|
if err != nil {
|
|
writeRaw(w, denyAnswer)
|
|
return
|
|
}
|
|
buf.WriteString(`,"override":`)
|
|
buf.Write(o)
|
|
}
|
|
buf.WriteString(`}}`)
|
|
writeRaw(w, buf.Bytes())
|
|
}
|
|
|
|
// overrideJSON is PHP array_merge of the presence defaults with the
|
|
// authorizer's overrides: the default keys keep their order and take the
|
|
// authorizer's value when it names them; extra keys follow, sorted.
|
|
func overrideJSON(overrides map[string]any) ([]byte, error) {
|
|
defaults := []struct {
|
|
key string
|
|
value any
|
|
}{
|
|
{"presence", map[string]bool{"value": true}},
|
|
{"join_leave", map[string]bool{"value": true}},
|
|
{"force_push_join_leave", map[string]bool{"value": false}},
|
|
}
|
|
var buf bytes.Buffer
|
|
buf.WriteByte('{')
|
|
seen := map[string]bool{}
|
|
write := func(key string, value any) error {
|
|
k, err := marshal(key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
v, err := marshal(value)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if buf.Len() > 1 {
|
|
buf.WriteByte(',')
|
|
}
|
|
buf.Write(k)
|
|
buf.WriteByte(':')
|
|
buf.Write(v)
|
|
return nil
|
|
}
|
|
for _, d := range defaults {
|
|
v := d.value
|
|
if o, ok := overrides[d.key]; ok {
|
|
v = o
|
|
}
|
|
seen[d.key] = true
|
|
if err := write(d.key, v); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
extra := make([]string, 0, len(overrides))
|
|
for k := range overrides {
|
|
if !seen[k] {
|
|
extra = append(extra, k)
|
|
}
|
|
}
|
|
sort.Strings(extra)
|
|
for _, k := range extra {
|
|
if err := write(k, overrides[k]); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
buf.WriteByte('}')
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func writeRaw(w http.ResponseWriter, body []byte) {
|
|
h := w.Header()
|
|
h.Set("Content-Type", "application/json")
|
|
h.Set("Cache-Control", "no-cache, private")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(bytes.TrimSuffix(body, []byte("\n")))
|
|
}
|