175 lines
6.1 KiB
Go
175 lines
6.1 KiB
Go
package handler
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/prabhupadasays/spsays-bff/internal/middleware"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/realtime"
|
|
"github.com/prabhupadasays/spsays-bff/pkg/response"
|
|
"go.uber.org/zap"
|
|
)
|
|
|
|
// ChannelRule authorizes one family of channels.
|
|
//
|
|
// Suffix is whatever follows Prefix in the channel name (a user ID today, a
|
|
// conversation ID once chat lands). Authorize reports whether userID — the
|
|
// authenticated caller — may subscribe. Returning an error means "couldn't
|
|
// decide" (a lookup failed) and surfaces as a 500; returning (false, nil)
|
|
// means "decided: no" and surfaces as a 403.
|
|
type ChannelRule struct {
|
|
Prefix string
|
|
Authorize func(ctx context.Context, userID, suffix string) (bool, error)
|
|
}
|
|
|
|
// BroadcastingAuthHandler implements the Pusher channel-authorization
|
|
// endpoint that pusher-js calls (as `authEndpoint`) before subscribing to a
|
|
// private- or presence- channel.
|
|
//
|
|
// This is the gate that makes private channels private: Soketi will not honour
|
|
// a subscription without a signature, and the signature is only minted here,
|
|
// after RequireAuth has established who the caller is and a rule has agreed
|
|
// they may listen. Unknown channels are denied by default, so adding a channel
|
|
// family is an explicit act.
|
|
type BroadcastingAuthHandler struct {
|
|
rt *realtime.SoketiClient
|
|
rules []ChannelRule
|
|
logger *zap.Logger
|
|
}
|
|
|
|
func NewBroadcastingAuthHandler(rt *realtime.SoketiClient, logger *zap.Logger, rules ...ChannelRule) *BroadcastingAuthHandler {
|
|
return &BroadcastingAuthHandler{rt: rt, rules: rules, logger: logger}
|
|
}
|
|
|
|
// NotificationsRule allows a user to subscribe to their own notification
|
|
// channel and nobody else's.
|
|
func NotificationsRule() ChannelRule {
|
|
return ChannelRule{
|
|
Prefix: "private-notifications.",
|
|
Authorize: func(_ context.Context, userID, suffix string) (bool, error) {
|
|
return suffix != "" && suffix == userID, nil
|
|
},
|
|
}
|
|
}
|
|
|
|
type broadcastingAuthResp struct {
|
|
Auth string `json:"auth"`
|
|
ChannelData string `json:"channel_data,omitempty"`
|
|
}
|
|
|
|
// writeAuth emits the auth payload BARE, deliberately bypassing the API's
|
|
// {success, data} envelope.
|
|
//
|
|
// This endpoint speaks Pusher's protocol, not our API's: pusher-js reads
|
|
// `auth` off the top level of the parsed body, so an enveloped response makes
|
|
// every private subscription fail. Errors still use response.Error — the
|
|
// client only checks the status code on that path.
|
|
func writeAuth(w http.ResponseWriter, payload broadcastingAuthResp) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusOK)
|
|
_ = json.NewEncoder(w).Encode(payload)
|
|
}
|
|
|
|
// Handle — POST /api/v1/broadcasting/auth. Session-authenticated (the cookie
|
|
// travels because the frontend sets credentials: "include").
|
|
//
|
|
// pusher-js posts application/x-www-form-urlencoded by default; JSON is
|
|
// accepted too so a custom client isn't forced into form encoding.
|
|
func (h *BroadcastingAuthHandler) Handle(w http.ResponseWriter, r *http.Request) {
|
|
userID := middleware.UserID(r.Context())
|
|
if userID == "" {
|
|
// RequireAuth should have caught this; refuse rather than sign.
|
|
response.Error(w, http.StatusUnauthorized, "authentication required")
|
|
return
|
|
}
|
|
|
|
socketID, channel := parseBroadcastingAuthReq(r)
|
|
if socketID == "" || channel == "" {
|
|
response.Error(w, http.StatusBadRequest, "socket_id and channel_name are required")
|
|
return
|
|
}
|
|
|
|
rule, suffix, found := h.match(channel)
|
|
if !found {
|
|
// Default-deny. An unrecognised channel is a client bug or an
|
|
// probe — either way there's nothing to sign.
|
|
h.logger.Warn("broadcasting auth: unknown channel",
|
|
zap.String("channel", channel), zap.String("user_id", userID))
|
|
response.Error(w, http.StatusForbidden, "channel not available")
|
|
return
|
|
}
|
|
|
|
allowed, err := rule.Authorize(r.Context(), userID, suffix)
|
|
if err != nil {
|
|
h.logger.Error("broadcasting auth: authorize failed",
|
|
zap.String("channel", channel), zap.Error(err))
|
|
response.Error(w, http.StatusInternalServerError, "could not authorize channel")
|
|
return
|
|
}
|
|
if !allowed {
|
|
h.logger.Warn("broadcasting auth: denied",
|
|
zap.String("channel", channel), zap.String("user_id", userID))
|
|
response.Error(w, http.StatusForbidden, "not permitted on this channel")
|
|
return
|
|
}
|
|
|
|
if strings.HasPrefix(channel, "presence-") {
|
|
auth, channelData, err := h.rt.AuthorizePresence(socketID, channel, realtime.PresenceData{UserID: userID})
|
|
if err != nil {
|
|
h.authError(w, err)
|
|
return
|
|
}
|
|
writeAuth(w, broadcastingAuthResp{Auth: auth, ChannelData: channelData})
|
|
return
|
|
}
|
|
|
|
auth, err := h.rt.AuthorizeChannel(socketID, channel)
|
|
if err != nil {
|
|
h.authError(w, err)
|
|
return
|
|
}
|
|
writeAuth(w, broadcastingAuthResp{Auth: auth})
|
|
}
|
|
|
|
// match finds the rule owning this channel and returns the part of the name
|
|
// after its prefix.
|
|
func (h *BroadcastingAuthHandler) match(channel string) (ChannelRule, string, bool) {
|
|
for _, rule := range h.rules {
|
|
if strings.HasPrefix(channel, rule.Prefix) {
|
|
return rule, strings.TrimPrefix(channel, rule.Prefix), true
|
|
}
|
|
}
|
|
return ChannelRule{}, "", false
|
|
}
|
|
|
|
func (h *BroadcastingAuthHandler) authError(w http.ResponseWriter, err error) {
|
|
if errors.Is(err, realtime.ErrNotConfigured) {
|
|
response.Error(w, http.StatusServiceUnavailable, "realtime is not configured")
|
|
return
|
|
}
|
|
h.logger.Error("broadcasting auth: signing failed", zap.Error(err))
|
|
response.Error(w, http.StatusBadRequest, err.Error())
|
|
}
|
|
|
|
// parseBroadcastingAuthReq reads socket_id / channel_name from either a form
|
|
// body (pusher-js default) or a JSON body.
|
|
func parseBroadcastingAuthReq(r *http.Request) (socketID, channel string) {
|
|
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
|
|
var body struct {
|
|
SocketID string `json:"socket_id"`
|
|
ChannelName string `json:"channel_name"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
return "", ""
|
|
}
|
|
return strings.TrimSpace(body.SocketID), strings.TrimSpace(body.ChannelName)
|
|
}
|
|
if err := r.ParseForm(); err != nil {
|
|
return "", ""
|
|
}
|
|
return strings.TrimSpace(r.PostFormValue("socket_id")), strings.TrimSpace(r.PostFormValue("channel_name"))
|
|
}
|