diff --git a/pkg/console/internal/events/events.go b/pkg/console/internal/events/events.go index caae64a0a4..bdbe3f0976 100644 --- a/pkg/console/internal/events/events.go +++ b/pkg/console/internal/events/events.go @@ -24,6 +24,7 @@ import ( "go.thethings.network/lorawan-stack/v3/pkg/auth/rights" "go.thethings.network/lorawan-stack/v3/pkg/config" "go.thethings.network/lorawan-stack/v3/pkg/console/internal/events/eventsmux" + "go.thethings.network/lorawan-stack/v3/pkg/console/internal/events/middleware" "go.thethings.network/lorawan-stack/v3/pkg/console/internal/events/subscriptions" "go.thethings.network/lorawan-stack/v3/pkg/events" "go.thethings.network/lorawan-stack/v3/pkg/log" @@ -36,6 +37,11 @@ import ( "nhooyr.io/websocket" ) +const ( + authorizationProtocolPrefix = "ttn.lorawan.v3.header.authorization.bearer." + protocolV1 = "ttn.lorawan.v3.console.internal.events.v1" +) + // Component is the interface of the component to the events API handler. type Component interface { task.Starter @@ -57,6 +63,7 @@ func (h *eventsHandler) RegisterRoutes(server *web.Server) { router.Use( mux.MiddlewareFunc(webmiddleware.Namespace("console/internal/events")), ratelimit.HTTPMiddleware(h.component.RateLimiter(), "http:console:internal:events"), + mux.MiddlewareFunc(middleware.ProtocolAuthentication(authorizationProtocolPrefix)), mux.MiddlewareFunc(webmiddleware.Metadata("Authorization")), ) router.Path("/").HandlerFunc(h.handleEvents).Methods(http.MethodGet) @@ -72,6 +79,7 @@ func (h *eventsHandler) handleEvents(w http.ResponseWriter, r *http.Request) { } conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + Subprotocols: []string{protocolV1}, InsecureSkipVerify: true, // CORS is not enabled for APIs. CompressionMode: websocket.CompressionContextTakeover, }) diff --git a/pkg/console/internal/events/middleware/auth.go b/pkg/console/internal/events/middleware/auth.go new file mode 100644 index 0000000000..66f2b71e20 --- /dev/null +++ b/pkg/console/internal/events/middleware/auth.go @@ -0,0 +1,78 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package middleware + +import ( + "fmt" + "net/http" + "net/textproto" + "strings" + + "go.thethings.network/lorawan-stack/v3/pkg/auth" +) + +var ( + protocolHeader = textproto.CanonicalMIMEHeaderKey("Sec-WebSocket-Protocol") + authorizationHeader = textproto.CanonicalMIMEHeaderKey("Authorization") + connectionHeader = textproto.CanonicalMIMEHeaderKey("Connection") + upgradeHeader = textproto.CanonicalMIMEHeaderKey("Upgrade") +) + +func isWebSocketRequest(r *http.Request) bool { + h := r.Header + return strings.EqualFold(h.Get(connectionHeader), "upgrade") && + strings.EqualFold(h.Get(upgradeHeader), "websocket") +} + +// ProtocolAuthentication returns a middleware that authenticates WebSocket requests using the subprotocol. +// The subprotocol must be prefixed with the given prefix. +// The token is extracted from the subprotocol and used to authenticate the request. +// If the token is valid, the subprotocol is removed from the request. +// If the token is invalid, the request is not authenticated. +func ProtocolAuthentication(prefix string) func(http.Handler) http.Handler { + prefixLen := len(prefix) + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !isWebSocketRequest(r) { + next.ServeHTTP(w, r) + return + } + if r.Header.Get(authorizationHeader) != "" { + next.ServeHTTP(w, r) + return + } + protocols := strings.Split(strings.TrimSpace(r.Header.Get(protocolHeader)), ",") + newProtocols := make([]string, 0, len(protocols)) + token := "" + for _, protocol := range protocols { + p := strings.TrimSpace(protocol) + if len(p) >= prefixLen && strings.EqualFold(prefix, p[:prefixLen]) { + token = p[prefixLen:] + continue + } + newProtocols = append(newProtocols, p) + } + if _, _, _, err := auth.SplitToken(token); err == nil { + if len(newProtocols) > 0 { + r.Header.Set(protocolHeader, strings.Join(newProtocols, ",")) + } else { + r.Header.Del(protocolHeader) + } + r.Header.Set(authorizationHeader, fmt.Sprintf("Bearer %s", token)) + } + next.ServeHTTP(w, r) + }) + } +} diff --git a/pkg/console/internal/events/middleware/middleware.go b/pkg/console/internal/events/middleware/middleware.go new file mode 100644 index 0000000000..61a7321356 --- /dev/null +++ b/pkg/console/internal/events/middleware/middleware.go @@ -0,0 +1,16 @@ +// Copyright © 2023 The Things Network Foundation, The Things Industries B.V. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Package middleware implements the events middleware. +package middleware diff --git a/pkg/console/internal/events/protocol/PROTOCOL.md b/pkg/console/internal/events/protocol/PROTOCOL.md index 168dc5980c..c29ddd5685 100644 --- a/pkg/console/internal/events/protocol/PROTOCOL.md +++ b/pkg/console/internal/events/protocol/PROTOCOL.md @@ -46,6 +46,11 @@ The authentication for the internal API is similar to other APIs available in Th Upon connecting, no authorization will take place - the endpoint only will check that the provided token is valid (i.e. exists and it is not expired). +The [standard WebSocket API](https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API) [does not support custom request headers](https://github.com/whatwg/websockets/issues/16). As a result of this limitation, the backend allows the Console to provide the token as a [protocol](https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#parameters). Specifically, given a `Bearer` token `t`, the following protocols should be provided to the `WebSocket` constructor: + +- `ttn.lorawan.v3.console.internal.events.v1` +- `ttn.lorawan.v3.header.authorization.bearer.t` + ### Message Format Both requests and responses sent over the WebSocket connection are JSON encoded. All messages are JSON objects and are required to contain at least the following two fields: