From 25ca43d16585b03e8ba109791ed6de3e2e3ec17b Mon Sep 17 00:00:00 2001 From: Florian Hotze Date: Tue, 31 Dec 2024 17:00:40 +0100 Subject: [PATCH] [websocket] Support token authentication through header (#4515) Signed-off-by: Florian Hotze --- .../openhab/core/io/rest/auth/AuthFilter.java | 7 +++ .../io/websocket/CommonWebSocketServlet.java | 56 ++++++++++++++++++- 2 files changed, 61 insertions(+), 2 deletions(-) diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java index 1d7230bc8c..90c0042a23 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java @@ -248,6 +248,13 @@ public class AuthFilter implements ContainerRequestFilter { } } + public @Nullable SecurityContext getSecurityContext(@Nullable String bearerToken) throws AuthenticationException { + if (bearerToken == null) { + return null; + } + return authenticateBearerToken(bearerToken); + } + public @Nullable SecurityContext getSecurityContext(HttpServletRequest request, boolean allowQueryToken) throws AuthenticationException, IOException { String altTokenHeader = request.getHeader(ALT_AUTH_HEADER); diff --git a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/CommonWebSocketServlet.java b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/CommonWebSocketServlet.java index 8109e5dd16..53aeba5f93 100644 --- a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/CommonWebSocketServlet.java +++ b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/CommonWebSocketServlet.java @@ -13,8 +13,11 @@ package org.openhab.core.io.websocket; import java.io.IOException; +import java.util.Base64; import java.util.HashMap; import java.util.Map; +import java.util.regex.Matcher; +import java.util.regex.Pattern; import javax.servlet.Servlet; import javax.servlet.ServletException; @@ -43,10 +46,19 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** - * The {@link CommonWebSocketServlet} provides the servlet for WebSocket connections + * The {@link CommonWebSocketServlet} provides the servlet for WebSocket connections. + * + *

+ * Clients can authorize in two ways: + *

* * @author Jan N. Klug - Initial contribution * @author Miguel Álvarez Díez - Refactor into a common servlet + * @author Florian Hotze - Support passing access token through Sec-WebSocket-Protocol header */ @NonNullByDefault @HttpWhiteboardServletName(CommonWebSocketServlet.SERVLET_PATH) @@ -55,6 +67,11 @@ import org.slf4j.LoggerFactory; public class CommonWebSocketServlet extends WebSocketServlet { private static final long serialVersionUID = 1L; + public static final String SEC_WEBSOCKET_PROTOCOL_HEADER = "Sec-WebSocket-Protocol"; + public static final String WEBSOCKET_PROTOCOL_DEFAULT = "org.openhab.ws.protocol.default"; + private static final Pattern WEBSOCKET_ACCESS_TOKEN_PATTERN = Pattern + .compile("org.openhab.ws.accessToken.base64.(?[A-Za-z0-9+/]*)"); + public static final String SERVLET_PATH = "/ws"; public static final String DEFAULT_ADAPTER_ID = EventWebSocketAdapter.ADAPTER_ID; @@ -94,7 +111,31 @@ public class CommonWebSocketServlet extends WebSocketServlet { if (servletUpgradeRequest == null || servletUpgradeResponse == null) { return null; } - if (isAuthorizedRequest(servletUpgradeRequest)) { + + String accessToken = null; + String secWebSocketProtocolHeader = servletUpgradeRequest.getHeader(SEC_WEBSOCKET_PROTOCOL_HEADER); + if (secWebSocketProtocolHeader != null) { // if the client sends the Sec-WebSocket-Protocol header + // respond with the default protocol + servletUpgradeResponse.setHeader(SEC_WEBSOCKET_PROTOCOL_HEADER, WEBSOCKET_PROTOCOL_DEFAULT); + // extract the base64 encoded access token from the requested protocols + Matcher matcher = WEBSOCKET_ACCESS_TOKEN_PATTERN.matcher(secWebSocketProtocolHeader); + if (matcher.find() && matcher.group("base64") != null) { + String base64 = matcher.group("base64"); + try { + accessToken = new String(Base64.getDecoder().decode(base64)); + } catch (IllegalArgumentException e) { + logger.warn("Invalid base64 encoded access token in Sec-WebSocket-Protocol header from {}.", + servletUpgradeRequest.getRemoteAddress()); + return null; + } + } else { + logger.warn("Invalid use of Sec-WebSocket-Protocol header from {}.", + servletUpgradeRequest.getRemoteAddress()); + return null; + } + } + + if (accessToken != null ? isAuthorizedRequest(accessToken) : isAuthorizedRequest(servletUpgradeRequest)) { String requestPath = servletUpgradeRequest.getRequestURI().getPath(); String pathPrefix = SERVLET_PATH + "/"; boolean useDefaultAdapter = requestPath.equals(pathPrefix) || !requestPath.startsWith(pathPrefix); @@ -122,6 +163,17 @@ public class CommonWebSocketServlet extends WebSocketServlet { return null; } + private boolean isAuthorizedRequest(String bearerToken) { + try { + var securityContext = authFilter.getSecurityContext(bearerToken); + return securityContext != null + && (securityContext.isUserInRole(Role.USER) || securityContext.isUserInRole(Role.ADMIN)); + } catch (AuthenticationException e) { + logger.warn("Error handling WebSocket authorization", e); + return false; + } + } + private boolean isAuthorizedRequest(ServletUpgradeRequest servletUpgradeRequest) { try { var securityContext = authFilter.getSecurityContext(servletUpgradeRequest.getHttpServletRequest(),