feat(11-03): re-authorize every Centrifugo subscribe through a namespace registry
- 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
This commit is contained in:
@@ -14,7 +14,9 @@ lighthouse is the SummerCMS counterpart of the WinterCMS websockets plugin. The
|
||||
|
||||
A driver may need HTTP endpoints, such as a token route for signed-in users or a callback the realtime server calls. It declares them as `lighthouse.Route` values, each tagged with a `lighthouse.Surface`. The application mounts them once with `lighthouse.Mount` and decides the guard, group and rate-limit bucket per surface. Switching drivers never edits the application's route file.
|
||||
|
||||
The `centrifugo` sub-package is the Centrifugo driver. It has a hand-rolled `net/http` client for the Centrifugo HTTP API, a token issuer with the claims of the WinterCMS `JwtTokenGenerator`, and the token route handler.
|
||||
Channel authorization is transport-neutral too. Plugins register a `lighthouse.Authorizer` per channel namespace on the service's `lighthouse.Registry`. The driver's subscribe endpoint asks the authorizer of the channel's namespace on every subscribe, so a user who loses access is denied the next time the client subscribes. Nothing is cached.
|
||||
|
||||
The `centrifugo` sub-package is the Centrifugo driver. It has a hand-rolled `net/http` client for the Centrifugo HTTP API, a token issuer with the claims of the WinterCMS `JwtTokenGenerator`, the token route handler, and the subscribe proxy handler.
|
||||
|
||||
## Features
|
||||
|
||||
@@ -23,10 +25,23 @@ The `centrifugo` sub-package is the Centrifugo driver. It has a hand-rolled `net
|
||||
- The `lighthouse.Publisher` interface (`Publish` for one channel, `Broadcast` for several) and the `lighthouse.Driver` interface, which adds `Name` and `Routes`.
|
||||
- Route mounting by surface: `lighthouse.UserAuth`, `lighthouse.ServerToServer` and `lighthouse.Public`. `lighthouse.Mount` puts user and public routes in `Group` and server-to-server routes in `GroupRaw`, each with the surface middleware followed by `lighthouse.Surfaces.Middleware`. A `lighthouse.UserAuth` route with no user middleware is refused, so a token route can never be mounted without a guard. Every route is validated before any is registered.
|
||||
- Users and actors: the application installs a `lighthouse.UserLookup` with `lighthouse.Service.SetUserLookup`. `lighthouse.Service.User` loads a `lighthouse.User` (id and display name). `lighthouse.Service.Actor` returns the `lighthouse.Actor` of a request, and `lighthouse.SystemActor` when there is no signed-in user or the principal is a backend admin.
|
||||
- Channel rules. A channel is `namespace:entity:id`, optionally prefixed once with `presence:`.
|
||||
- `lighthouse.ParseChannel` returns the namespace. It returns "" for a `presence:presence:` prefix or for more than three segments. The lookup is byte-exact and case-sensitive.
|
||||
- `lighthouse.ChannelID` returns segment 1 converted with PHP's `(int)` cast (`lighthouse.PHPInt`): `5abc` is 5, `abc` is 0, and out-of-range values saturate.
|
||||
- `lighthouse.FormatChannels` lowercases channel names and applies the broadcast namespace prefix.
|
||||
- Authorizer registry: `lighthouse.Registry` (from `lighthouse.Service.Registry`) maps namespaces to a `lighthouse.Authorizer` or `lighthouse.AuthorizerFunc`. Registering an empty namespace, a namespace that contains `:`, a nil authorizer or a namespace twice is an error. `lighthouse.Registry.Namespaces` is sorted. An authorizer returns `lighthouse.Allowed` (optionally with info, capabilities and overrides) or `lighthouse.Denied` with an internal reason that only reaches the logs. It reads the realtime client id with `lighthouse.ClientID`.
|
||||
- Centrifugo driver (`centrifugo.Driver`, driver name `centrifugo`):
|
||||
- `centrifugo.Client` POSTs `publish`, `broadcast`, `presence` and `unsubscribe` calls with `Authorization: apikey <key>` and a 5 s timeout. The publish body is `{"channel":…,"data":{"event":…,"payload":…,"timestamp":"…+00:00"}}`, with an empty payload sent as `[]`. Any 2xx status counts as success. With an empty API key nothing is sent and the call returns `centrifugo.ErrNotConfigured`. The key never appears in logs or errors.
|
||||
- `centrifugo.TokenIssuer` signs HS256 tokens with five generators, the same as the WinterCMS generator: `ForUser` (claims `sub`, `exp`, `info` with only `name`), `Subscription`, `Anonymous` (`sub` "" and a 5-minute lifetime), `ForIdentifier` (an empty `info` is encoded as `[]`) and `SubscriptionForIdentifier`. It refuses to sign with an empty secret.
|
||||
- `centrifugo.TokenHandler` serves the token route. It answers 401 `{"error":"Unauthorized"}` when no user is signed in, 503 `{"error":"WebSocket not configured"}` when the token secret is empty, and otherwise 200 `{"token":"…"}`. It sends `Cache-Control: no-cache, private` and no trailing newline.
|
||||
- `centrifugo.ProxyHandler` is the subscribe proxy endpoint. Centrifugo reads a non-200 status as an internal error, so every answer is HTTP 200. The checks run in this order:
|
||||
1. `X-Centrifugo-Secret` must equal `realtime.centrifugo.proxy_secret`, compared in constant time. An empty configured secret denies every subscribe.
|
||||
2. An empty or `"0"` user denies. The user may arrive as a JSON string or number; any other type counts as empty.
|
||||
3. A missing channel denies. Centrifugo always sends one.
|
||||
4. The channel's namespace must have a registered authorizer.
|
||||
5. The authorizer receives the user id and the full original channel.
|
||||
|
||||
An allow answers `{"result":{"info":…}}`, with an empty info encoded as `[]`. A `presence:` channel also gets `allow` (the authorizer's capabilities, or `["prs"]`) and `override`. The override starts from the defaults `presence` and `join_leave` true and `force_push_join_leave` false, then applies the authorizer's overrides. Every deny answers `{"error":{"code":403,"message":"Access denied"}}` and logs `Subscription denied` at Warn with the reason, and never either secret. The request body is capped at 64 KiB.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -38,7 +53,7 @@ centrifugo:
|
||||
token_secret: "" # set with SUMMER_REALTIME__CENTRIFUGO__TOKEN_SECRET
|
||||
```
|
||||
|
||||
A plugin imports the driver package for its side effect, builds the service at Boot and installs a user lookup:
|
||||
A plugin imports the driver package for its side effect, builds the service at Boot, installs a user lookup and registers its channel authorizers:
|
||||
|
||||
```go
|
||||
package acme
|
||||
@@ -60,7 +75,14 @@ func (p *Plugin) Boot(app *backpack.App) error {
|
||||
svc.SetUserLookup(func(ctx context.Context, id uint) (lighthouse.User, bool, error) {
|
||||
return lookupAcmeUser(ctx, id) // the application's own user model
|
||||
})
|
||||
return nil
|
||||
// Allow room:{id} to members only; re-checked on every subscribe.
|
||||
return svc.Registry().Register("room", lighthouse.AuthorizerFunc(
|
||||
func(ctx context.Context, userID uint, channel string) lighthouse.Result {
|
||||
if isRoomMember(ctx, userID, lighthouse.ChannelID(channel)) {
|
||||
return lighthouse.Allowed(nil)
|
||||
}
|
||||
return lighthouse.Denied("not a room member")
|
||||
}))
|
||||
}
|
||||
```
|
||||
|
||||
@@ -92,7 +114,7 @@ for _, pub := range mem.Publications() {
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `lighthouse.From(app)` | The app's `*lighthouse.Service`, built and published on first use. |
|
||||
| `lighthouse.Service` | The realtime service: `Driver`, `Logger`, `Namespace`, `Queue`, `Timeout`, `SetUserLookup`, `User`, `Actor`. |
|
||||
| `lighthouse.Service` | The realtime service: `Driver`, `Registry`, `Logger`, `Namespace`, `Queue`, `Timeout`, `SetUserLookup`, `User`, `Actor`. |
|
||||
| `lighthouse.Publisher` | `Publish(ctx, channel, event, payload)` and `Broadcast(ctx, channels, event, payload)`. |
|
||||
| `lighthouse.Driver` | `lighthouse.Publisher` plus `Name()` and `Routes()`. |
|
||||
| `lighthouse.DriverFactory` | `func(app, svc) (lighthouse.Driver, error)`. |
|
||||
@@ -102,6 +124,11 @@ for _, pub := range mem.Publications() {
|
||||
| `lighthouse.Surface`, `lighthouse.UserAuth`, `lighthouse.ServerToServer`, `lighthouse.Public` | Who calls a route. |
|
||||
| `lighthouse.Surfaces` | Application middleware per surface plus `Middleware` for every route. |
|
||||
| `lighthouse.Mount(r, driver, surfaces)` | Registers a driver's routes. |
|
||||
| `lighthouse.Authorizer`, `lighthouse.AuthorizerFunc` | `Authorize(ctx, userID, channel) lighthouse.Result`. |
|
||||
| `lighthouse.Result`, `lighthouse.Allowed`, `lighthouse.Denied` | A subscribe decision: `Allowed`, `Info`, `Capabilities`, `Overrides` and `Reason()`. |
|
||||
| `lighthouse.Registry`, `lighthouse.NewRegistry` | Namespace to authorizer map: `Register`, `Get`, `Namespaces`. |
|
||||
| `lighthouse.ParseChannel`, `lighthouse.ChannelID`, `lighthouse.PHPInt`, `lighthouse.FormatChannels` | Channel rules. |
|
||||
| `lighthouse.WithClientID`, `lighthouse.ClientID` | The realtime client id of a subscribe request, carried in the context. |
|
||||
| `lighthouse.User`, `lighthouse.UserLookup` | A user id with a display name, and the application's lookup. |
|
||||
| `lighthouse.Actor`, `lighthouse.SystemActor` | Who caused a broadcast: `{"user_id":…,"name":…}`. |
|
||||
| `lighthouse.DurationSetting(cfg, path)` | Reads a duration string or an integer number of seconds. |
|
||||
@@ -111,11 +138,12 @@ for _, pub := range mem.Publications() {
|
||||
|
||||
| Identifier | Description |
|
||||
|------------|-------------|
|
||||
| `centrifugo.Config`, `centrifugo.LoadConfig` | The `realtime.centrifugo.*` settings with their defaults. |
|
||||
| `centrifugo.Config`, `centrifugo.LoadConfig` | The `realtime.centrifugo.*` settings with their defaults, plus `TrustedProxies` from `http.trusted_proxies` for logging client IPs. |
|
||||
| `centrifugo.Client`, `centrifugo.NewClient` | HTTP API client: `Publish`, `Broadcast`, `Presence`, `Unsubscribe`, `Enabled`, `DebugInfo`. |
|
||||
| `centrifugo.DebugInfo` | `api_url`, `enabled`, `api_key_set`. |
|
||||
| `centrifugo.TokenIssuer`, `centrifugo.NewTokenIssuer` | HS256 token generators: `ForUser`, `Subscription`, `Anonymous`, `ForIdentifier`, `SubscriptionForIdentifier`, `Configured`. |
|
||||
| `centrifugo.TokenHandler(svc, issuer)` | The token route handler. |
|
||||
| `centrifugo.ProxyHandler(svc, cfg)` | The subscribe proxy handler. |
|
||||
| `centrifugo.Driver`, `centrifugo.NewDriver`, `centrifugo.DriverName` | The `lighthouse.Driver`, with `Client`, `Issuer` and `Config` accessors. |
|
||||
| `centrifugo.ErrNotConfigured` | Returned when the API key or token secret an operation needs is empty. |
|
||||
|
||||
@@ -138,7 +166,7 @@ for _, pub := range mem.Publications() {
|
||||
|
||||
## Dependencies
|
||||
|
||||
- `backpack`, `bouncer`, `compass`, `pact` and `wire` from this repository.
|
||||
- `backpack`, `bouncer`, `compass`, `pact` and `wire` from this repository; the centrifugo driver also uses `surf` for the client IP.
|
||||
- `github.com/golang-jwt/jwt/v5` (centrifugo token signing).
|
||||
- The Centrifugo client is plain `net/http`; no Centrifugo SDK is used.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user