From e3396c9477d9282cd29496331884e07b8c41c17d Mon Sep 17 00:00:00 2001 From: GiviMAD Date: Sat, 10 Jun 2023 15:35:23 +0200 Subject: [PATCH] [websocket] Allow registering websocket adapters (#3622) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [WebSocket] Allow register websocket handlers Signed-off-by: Miguel Álvarez --- .../AnonymousUserSecurityContext.java | 2 +- .../rest/auth/{internal => }/AuthFilter.java | 110 +++++++----- .../ExpiringUserSecurityContextCache.java | 8 +- .../auth/{internal => }/AuthFilterTest.java | 7 +- bundles/org.openhab.core.io.websocket/pom.xml | 5 + .../io/websocket/CommonWebSocketServlet.java | 136 +++++++++++++++ .../core/io/websocket/EventWebSocket.java | 10 +- .../io/websocket/EventWebSocketAdapter.java | 80 +++++++++ .../io/websocket/EventWebSocketServlet.java | 160 ------------------ .../core/io/websocket/WebSocketAdapter.java | 44 +++++ .../websocket/CommonWebSocketServletTest.java | 99 +++++++++++ .../core/io/websocket/EventWebSocketTest.java | 2 +- .../openhab-core/src/main/feature/feature.xml | 2 + 13 files changed, 449 insertions(+), 216 deletions(-) rename bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/{internal => }/AnonymousUserSecurityContext.java (96%) rename bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/{internal => }/AuthFilter.java (78%) rename bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/{internal => }/AuthFilterTest.java (93%) create mode 100644 bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/CommonWebSocketServlet.java create mode 100644 bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketAdapter.java delete mode 100644 bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketServlet.java create mode 100644 bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/WebSocketAdapter.java create mode 100644 bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/CommonWebSocketServletTest.java diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AnonymousUserSecurityContext.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AnonymousUserSecurityContext.java similarity index 96% rename from bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AnonymousUserSecurityContext.java rename to bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AnonymousUserSecurityContext.java index aa3bbae99e4..ec4a0fa4427 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AnonymousUserSecurityContext.java +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AnonymousUserSecurityContext.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.core.io.rest.auth.internal; +package org.openhab.core.io.rest.auth; import java.security.Principal; diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java similarity index 78% rename from bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java rename to bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java index 7faa3b41051..38bbfbca7dc 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/AuthFilter.java +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/AuthFilter.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.core.io.rest.auth.internal; +package org.openhab.core.io.rest.auth; import java.io.IOException; import java.net.InetAddress; @@ -53,6 +53,7 @@ import org.openhab.core.config.core.ConfigurableService; import org.openhab.core.io.rest.JSONResponse; import org.openhab.core.io.rest.RESTConstants; +import org.openhab.core.io.rest.auth.internal.*; import org.osgi.framework.Constants; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; @@ -77,7 +78,8 @@ * @author Miguel Álvarez - Add trusted networks for implicit user role */ @PreMatching -@Component(configurationPid = "org.openhab.restauth", property = Constants.SERVICE_PID + "=org.openhab.restauth") +@Component(configurationPid = "org.openhab.restauth", property = Constants.SERVICE_PID + + "=org.openhab.restauth", service = AuthFilter.class) @ConfigurableService(category = "system", label = "API Security", description_uri = AuthFilter.CONFIG_URI) @JaxrsExtension @JaxrsApplicationSelect("(" + JaxrsWhiteboardConstants.JAX_RS_NAME + "=" + RESTConstants.JAX_RS_NAME + ")") @@ -232,56 +234,80 @@ private SecurityContext authenticateBasicAuth(String credentialString) throws Au public void filter(@Nullable ContainerRequestContext requestContext) throws IOException { if (requestContext != null) { try { - String altTokenHeader = requestContext.getHeaderString(ALT_AUTH_HEADER); - if (altTokenHeader != null) { - requestContext.setSecurityContext(authenticateBearerToken(altTokenHeader)); - return; + SecurityContext sc = getSecurityContext(servletRequest, false); + if (sc != null) { + requestContext.setSecurityContext(sc); } + } catch (AuthenticationException e) { + logger.warn("Unauthorized API request from {}: {}", getClientIp(servletRequest), e.getMessage()); + requestContext.abortWith(JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "Invalid credentials")); + } + } + } - String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); - if (authHeader != null) { - String[] authParts = authHeader.split(" "); - if (authParts.length == 2) { - String authType = authParts[0]; - String authValue = authParts[1]; - if ("Bearer".equalsIgnoreCase(authType)) { - requestContext.setSecurityContext(authenticateBearerToken(authValue)); - return; - } else if ("Basic".equalsIgnoreCase(authType)) { - String[] decodedCredentials = new String(Base64.getDecoder().decode(authValue), "UTF-8") - .split(":"); - if (decodedCredentials.length > 2) { - throw new AuthenticationException("Invalid Basic authentication credential format"); - } - switch (decodedCredentials.length) { - case 1: - requestContext.setSecurityContext(authenticateBearerToken(decodedCredentials[0])); - break; - case 2: - if (!allowBasicAuth) { - throw new AuthenticationException( - "Basic authentication with username/password is not allowed"); - } - requestContext.setSecurityContext(authenticateBasicAuth(authValue)); - } - } + public @Nullable SecurityContext getSecurityContext(HttpServletRequest request, boolean allowQueryToken) + throws AuthenticationException, IOException { + String altTokenHeader = request.getHeader(ALT_AUTH_HEADER); + if (altTokenHeader != null) { + return authenticateBearerToken(altTokenHeader); + } + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + String authType = null; + String authValue = null; + boolean authFromQuery = false; + if (authHeader != null) { + String[] authParts = authHeader.split(" "); + if (authParts.length == 2) { + authType = authParts[0]; + authValue = authParts[1]; + } + } else if (allowQueryToken) { + Map parameterMap = request.getParameterMap(); + String[] accessToken = parameterMap.get("accessToken"); + if (accessToken != null && accessToken.length > 0) { + authValue = accessToken[0]; + authFromQuery = true; + } + } + if (authValue != null) { + if (authFromQuery) { + try { + return authenticateBearerToken(authValue); + } catch (AuthenticationException e) { + if (allowBasicAuth) { + return authenticateBasicAuth(authValue); } - } else if (isImplicitUserRole(requestContext)) { - requestContext.setSecurityContext(new AnonymousUserSecurityContext()); } - } catch (AuthenticationException e) { - logger.warn("Unauthorized API request from {}: {}", getClientIp(requestContext), e.getMessage()); - requestContext.abortWith(JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "Invalid credentials")); + } else if ("Bearer".equalsIgnoreCase(authType)) { + return authenticateBearerToken(authValue); + } else if ("Basic".equalsIgnoreCase(authType)) { + String[] decodedCredentials = new String(Base64.getDecoder().decode(authValue), "UTF-8").split(":"); + if (decodedCredentials.length > 2) { + throw new AuthenticationException("Invalid Basic authentication credential format"); + } + switch (decodedCredentials.length) { + case 1: + return authenticateBearerToken(decodedCredentials[0]); + case 2: + if (!allowBasicAuth) { + throw new AuthenticationException( + "Basic authentication with username/password is not allowed"); + } + return authenticateBasicAuth(authValue); + } } + } else if (isImplicitUserRole(servletRequest)) { + return new AnonymousUserSecurityContext(); } + return null; } - private boolean isImplicitUserRole(ContainerRequestContext requestContext) { + private boolean isImplicitUserRole(HttpServletRequest request) { if (implicitUserRole) { return true; } try { - byte[] clientAddress = InetAddress.getByName(getClientIp(requestContext)).getAddress(); + byte[] clientAddress = InetAddress.getByName(getClientIp(request)).getAddress(); return trustedNetworks.stream().anyMatch(networkCIDR -> networkCIDR.isInRange(clientAddress)); } catch (IOException e) { logger.debug("Error validating trusted networks: {}", e.getMessage()); @@ -303,8 +329,8 @@ private List parseTrustedNetworks(String value) { return cidrList; } - private String getClientIp(ContainerRequestContext requestContext) throws UnknownHostException { - String ipForwarded = Objects.requireNonNullElse(requestContext.getHeaderString("x-forwarded-for"), ""); + private String getClientIp(HttpServletRequest request) throws UnknownHostException { + String ipForwarded = Objects.requireNonNullElse(request.getHeader("x-forwarded-for"), ""); String clientIp = ipForwarded.split(",")[0]; return clientIp.isBlank() ? servletRequest.getRemoteAddr() : clientIp; } diff --git a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/ExpiringUserSecurityContextCache.java b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/ExpiringUserSecurityContextCache.java index a5dab8e6499..5112d10e682 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/ExpiringUserSecurityContextCache.java +++ b/bundles/org.openhab.core.io.rest.auth/src/main/java/org/openhab/core/io/rest/auth/internal/ExpiringUserSecurityContextCache.java @@ -37,7 +37,7 @@ public class ExpiringUserSecurityContextCache { private int calls = 0; - ExpiringUserSecurityContextCache(long expirationTime) { + public ExpiringUserSecurityContextCache(long expirationTime) { this.keepPeriod = expirationTime; entryMap = new LinkedHashMap<>() { private static final long serialVersionUID = -1220310861591070462L; @@ -48,7 +48,7 @@ protected boolean removeEldestEntry(Map.@Nullable Entry eldest) { }; } - synchronized @Nullable UserSecurityContext get(String key) { + public synchronized @Nullable UserSecurityContext get(String key) { calls++; if (calls >= CLEANUP_FREQUENCY) { new HashSet<>(entryMap.keySet()).forEach(k -> getEntry(k)); @@ -61,11 +61,11 @@ protected boolean removeEldestEntry(Map.@Nullable Entry eldest) { return null; } - synchronized void put(String key, UserSecurityContext value) { + public synchronized void put(String key, UserSecurityContext value) { entryMap.put(key, new Entry(System.currentTimeMillis(), value)); } - synchronized void clear() { + public synchronized void clear() { entryMap.clear(); } diff --git a/bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/internal/AuthFilterTest.java b/bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/AuthFilterTest.java similarity index 93% rename from bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/internal/AuthFilterTest.java rename to bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/AuthFilterTest.java index a4d98fefa2d..2f5bca9c806 100644 --- a/bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/internal/AuthFilterTest.java +++ b/bundles/org.openhab.core.io.rest.auth/src/test/java/org/openhab/core/io/rest/auth/AuthFilterTest.java @@ -10,7 +10,7 @@ * * SPDX-License-Identifier: EPL-2.0 */ -package org.openhab.core.io.rest.auth.internal; +package org.openhab.core.io.rest.auth; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; @@ -32,6 +32,7 @@ import org.mockito.junit.jupiter.MockitoSettings; import org.mockito.quality.Strictness; import org.openhab.core.auth.UserRegistry; +import org.openhab.core.io.rest.auth.internal.JwtHelper; /** * The {@link AuthFilterTest} is a @@ -79,7 +80,7 @@ public void noImplicitUserRoleDeniesAccess() throws IOException { public void trustedNetworkAllowsAccessIfForwardedHeaderMatches() throws IOException { authFilter.activate(Map.of(AuthFilter.CONFIG_IMPLICIT_USER_ROLE, false, AuthFilter.CONFIG_TRUSTED_NETWORKS, "192.168.1.0/24")); - when(containerRequestContext.getHeaderString("x-forwarded-for")).thenReturn("192.168.1.100"); + when(servletRequest.getHeader("x-forwarded-for")).thenReturn("192.168.1.100"); authFilter.filter(containerRequestContext); verify(containerRequestContext).setSecurityContext(any()); @@ -89,7 +90,7 @@ public void trustedNetworkAllowsAccessIfForwardedHeaderMatches() throws IOExcept public void trustedNetworkDeniesAccessIfForwardedHeaderDoesNotMatch() throws IOException { authFilter.activate(Map.of(AuthFilter.CONFIG_IMPLICIT_USER_ROLE, false, AuthFilter.CONFIG_TRUSTED_NETWORKS, "192.168.1.0/24")); - when(containerRequestContext.getHeaderString("x-forwarded-for")).thenReturn("192.168.2.100"); + when(servletRequest.getHeader("x-forwarded-for")).thenReturn("192.168.2.100"); authFilter.filter(containerRequestContext); verify(containerRequestContext, never()).setSecurityContext(any()); diff --git a/bundles/org.openhab.core.io.websocket/pom.xml b/bundles/org.openhab.core.io.websocket/pom.xml index 4104b939b21..3d4b017b3b3 100644 --- a/bundles/org.openhab.core.io.websocket/pom.xml +++ b/bundles/org.openhab.core.io.websocket/pom.xml @@ -20,6 +20,11 @@ org.openhab.core ${project.version} + + org.openhab.core.bundles + org.openhab.core.io.rest.auth + ${project.version} + 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 new file mode 100644 index 00000000000..7937ef0e39b --- /dev/null +++ b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/CommonWebSocketServlet.java @@ -0,0 +1,136 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.websocket; + +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +import javax.servlet.Servlet; +import javax.servlet.ServletException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.eclipse.jetty.websocket.server.WebSocketServerFactory; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; +import org.eclipse.jetty.websocket.servlet.WebSocketCreator; +import org.eclipse.jetty.websocket.servlet.WebSocketServlet; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.auth.Role; +import org.openhab.core.io.rest.auth.AuthFilter; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; +import org.osgi.service.component.annotations.ReferenceCardinality; +import org.osgi.service.component.annotations.ReferencePolicy; +import org.osgi.service.http.NamespaceException; +import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletName; +import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletPattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link CommonWebSocketServlet} provides the servlet for WebSocket connections + * + * @author Jan N. Klug - Initial contribution + * @author Miguel Álvarez Díez - Refactor into a common servlet + */ +@NonNullByDefault +@HttpWhiteboardServletName(CommonWebSocketServlet.SERVLET_PATH) +@HttpWhiteboardServletPattern(CommonWebSocketServlet.SERVLET_PATH + "/*") +@Component(immediate = true, service = { Servlet.class }) +public class CommonWebSocketServlet extends WebSocketServlet { + private static final long serialVersionUID = 1L; + + public static final String SERVLET_PATH = "/ws"; + + public static final String DEFAULT_ADAPTER_ID = EventWebSocketAdapter.ADAPTER_ID; + + private final Map connectionHandlers = new HashMap<>(); + private final AuthFilter authFilter; + + @SuppressWarnings("unused") + private @Nullable WebSocketServerFactory importNeeded; + + @Activate + public CommonWebSocketServlet(@Reference AuthFilter authFilter) throws ServletException, NamespaceException { + this.authFilter = authFilter; + } + + @Override + public void configure(@NonNullByDefault({}) WebSocketServletFactory webSocketServletFactory) { + webSocketServletFactory.getPolicy().setIdleTimeout(10000); + webSocketServletFactory.setCreator(new CommonWebSocketCreator()); + } + + @Reference(cardinality = ReferenceCardinality.MULTIPLE, policy = ReferencePolicy.DYNAMIC) + protected void addWebSocketAdapter(WebSocketAdapter wsAdapter) { + this.connectionHandlers.put(wsAdapter.getId(), wsAdapter); + } + + protected void removeWebSocketAdapter(WebSocketAdapter wsAdapter) { + this.connectionHandlers.remove(wsAdapter.getId()); + } + + private class CommonWebSocketCreator implements WebSocketCreator { + private final Logger logger = LoggerFactory.getLogger(CommonWebSocketCreator.class); + + @Override + public @Nullable Object createWebSocket(@Nullable ServletUpgradeRequest servletUpgradeRequest, + @Nullable ServletUpgradeResponse servletUpgradeResponse) { + if (servletUpgradeRequest == null || servletUpgradeResponse == null) { + return null; + } + if (isAuthorizedRequest(servletUpgradeRequest)) { + String requestPath = servletUpgradeRequest.getRequestURI().getPath(); + String pathPrefix = SERVLET_PATH + "/"; + boolean useDefaultAdapter = requestPath.equals(pathPrefix) || !requestPath.startsWith(pathPrefix); + WebSocketAdapter wsAdapter; + if (!useDefaultAdapter) { + String adapterId = requestPath.substring(pathPrefix.length()); + wsAdapter = connectionHandlers.get(adapterId); + if (wsAdapter == null) { + logger.warn("Missing WebSocket adapter for path {}", adapterId); + return null; + } + } else { + wsAdapter = connectionHandlers.get(DEFAULT_ADAPTER_ID); + if (wsAdapter == null) { + logger.warn("Default WebSocket adapter is missing"); + return null; + } + } + logger.debug("New connection handled by {}", wsAdapter.getId()); + return wsAdapter.createWebSocket(servletUpgradeRequest, servletUpgradeResponse); + } else { + logger.warn("Unauthenticated request to create a websocket from {}.", + servletUpgradeRequest.getRemoteAddress()); + } + return null; + } + + private boolean isAuthorizedRequest(ServletUpgradeRequest servletUpgradeRequest) { + try { + var securityContext = authFilter.getSecurityContext(servletUpgradeRequest.getHttpServletRequest(), + true); + return securityContext != null + && (securityContext.isUserInRole(Role.USER) || securityContext.isUserInRole(Role.ADMIN)); + } catch (AuthenticationException | IOException e) { + logger.warn("Error handling WebSocket authorization", e); + return false; + } + } + } +} diff --git a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocket.java b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocket.java index 78d833c285b..f37bb4cc8e2 100644 --- a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocket.java +++ b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocket.java @@ -52,7 +52,7 @@ public class EventWebSocket { private final Logger logger = LoggerFactory.getLogger(EventWebSocket.class); - private final EventWebSocketServlet servlet; + private final EventWebSocketAdapter wsAdapter; private final Gson gson; private final EventPublisher eventPublisher; private final ItemEventUtility itemEventUtility; @@ -64,9 +64,9 @@ public class EventWebSocket { private List typeFilter = List.of(); private List sourceFilter = List.of(); - public EventWebSocket(Gson gson, EventWebSocketServlet servlet, ItemEventUtility itemEventUtility, + public EventWebSocket(Gson gson, EventWebSocketAdapter wsAdapter, ItemEventUtility itemEventUtility, EventPublisher eventPublisher) { - this.servlet = servlet; + this.wsAdapter = wsAdapter; this.gson = gson; this.itemEventUtility = itemEventUtility; this.eventPublisher = eventPublisher; @@ -74,7 +74,7 @@ public EventWebSocket(Gson gson, EventWebSocketServlet servlet, ItemEventUtility @OnWebSocketClose public void onClose(int statusCode, String reason) { - this.servlet.unregisterListener(this); + this.wsAdapter.unregisterListener(this); remoteIdentifier = ""; this.session = null; this.remoteEndpoint = null; @@ -86,7 +86,7 @@ public void onConnect(Session session) { RemoteEndpoint remoteEndpoint = session.getRemote(); this.remoteEndpoint = remoteEndpoint; this.remoteIdentifier = remoteEndpoint.getInetSocketAddress().toString(); - this.servlet.registerListener(this); + this.wsAdapter.registerListener(this); } @OnWebSocketMessage diff --git a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketAdapter.java b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketAdapter.java new file mode 100644 index 00000000000..6ef61b804cb --- /dev/null +++ b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketAdapter.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.websocket; + +import java.util.Set; +import java.util.concurrent.CopyOnWriteArraySet; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.events.EventSubscriber; +import org.openhab.core.items.ItemRegistry; +import org.osgi.service.component.annotations.Activate; +import org.osgi.service.component.annotations.Component; +import org.osgi.service.component.annotations.Reference; + +import com.google.gson.Gson; + +/** + * The {@link EventWebSocketAdapter} allows subscription to oh events over WebSocket + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@Component(immediate = true, service = { EventSubscriber.class, WebSocketAdapter.class }) +public class EventWebSocketAdapter implements EventSubscriber, WebSocketAdapter { + public static final String ADAPTER_ID = "event-subscriber"; + private final Gson gson = new Gson(); + private final EventPublisher eventPublisher; + + private final ItemEventUtility itemEventUtility; + private final Set webSockets = new CopyOnWriteArraySet<>(); + + @Activate + public EventWebSocketAdapter(@Reference EventPublisher eventPublisher, @Reference ItemRegistry itemRegistry) { + this.eventPublisher = eventPublisher; + itemEventUtility = new ItemEventUtility(gson, itemRegistry); + } + + @Override + public Set getSubscribedEventTypes() { + return Set.of(EventSubscriber.ALL_EVENT_TYPES); + } + + @Override + public void receive(Event event) { + webSockets.forEach(ws -> ws.processEvent(event)); + } + + public void registerListener(EventWebSocket eventWebSocket) { + webSockets.add(eventWebSocket); + } + + public void unregisterListener(EventWebSocket eventWebSocket) { + webSockets.remove(eventWebSocket); + } + + @Override + public String getId() { + return ADAPTER_ID; + } + + @Override + public Object createWebSocket(ServletUpgradeRequest servletUpgradeRequest, + ServletUpgradeResponse servletUpgradeResponse) { + return new EventWebSocket(gson, EventWebSocketAdapter.this, itemEventUtility, eventPublisher); + } +} diff --git a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketServlet.java b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketServlet.java deleted file mode 100644 index 1eb176860e3..00000000000 --- a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/EventWebSocketServlet.java +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Copyright (c) 2010-2023 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.core.io.websocket; - -import java.util.Base64; -import java.util.List; -import java.util.Map; -import java.util.Set; -import java.util.concurrent.CopyOnWriteArraySet; - -import javax.servlet.Servlet; -import javax.servlet.ServletException; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.eclipse.jdt.annotation.Nullable; -import org.eclipse.jetty.websocket.server.WebSocketServerFactory; -import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; -import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; -import org.eclipse.jetty.websocket.servlet.WebSocketCreator; -import org.eclipse.jetty.websocket.servlet.WebSocketServlet; -import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; -import org.openhab.core.auth.Authentication; -import org.openhab.core.auth.AuthenticationException; -import org.openhab.core.auth.Credentials; -import org.openhab.core.auth.Role; -import org.openhab.core.auth.User; -import org.openhab.core.auth.UserApiTokenCredentials; -import org.openhab.core.auth.UserRegistry; -import org.openhab.core.auth.UsernamePasswordCredentials; -import org.openhab.core.events.Event; -import org.openhab.core.events.EventPublisher; -import org.openhab.core.events.EventSubscriber; -import org.openhab.core.items.ItemRegistry; -import org.osgi.service.component.annotations.Activate; -import org.osgi.service.component.annotations.Component; -import org.osgi.service.component.annotations.Reference; -import org.osgi.service.http.NamespaceException; -import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletName; -import org.osgi.service.http.whiteboard.propertytypes.HttpWhiteboardServletPattern; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -import com.google.gson.Gson; - -/** - * The {@link EventWebSocketServlet} provides the servlet for WebSocket connections - * - * @author Jan N. Klug - Initial contribution - */ -@NonNullByDefault -@HttpWhiteboardServletName(EventWebSocketServlet.SERVLET_PATH) -@HttpWhiteboardServletPattern(EventWebSocketServlet.SERVLET_PATH + "/*") -@Component(immediate = true, service = { EventSubscriber.class, Servlet.class }) -public class EventWebSocketServlet extends WebSocketServlet implements EventSubscriber { - private static final long serialVersionUID = 1L; - - public static final String SERVLET_PATH = "/ws"; - private final Gson gson = new Gson(); - private final UserRegistry userRegistry; - private final EventPublisher eventPublisher; - - private final ItemEventUtility itemEventUtility; - private final Set webSockets = new CopyOnWriteArraySet<>(); - - @SuppressWarnings("unused") - private @Nullable WebSocketServerFactory importNeeded; - - @Activate - public EventWebSocketServlet(@Reference UserRegistry userRegistry, @Reference EventPublisher eventPublisher, - @Reference ItemRegistry itemRegistry) throws ServletException, NamespaceException { - this.userRegistry = userRegistry; - this.eventPublisher = eventPublisher; - - itemEventUtility = new ItemEventUtility(gson, itemRegistry); - } - - @Override - public void configure(@NonNullByDefault({}) WebSocketServletFactory webSocketServletFactory) { - webSocketServletFactory.getPolicy().setIdleTimeout(10000); - webSocketServletFactory.setCreator(new EventWebSocketCreator()); - } - - @Override - public Set getSubscribedEventTypes() { - return Set.of(EventSubscriber.ALL_EVENT_TYPES); - } - - @Override - public void receive(Event event) { - webSockets.forEach(ws -> ws.processEvent(event)); - } - - public void registerListener(EventWebSocket eventWebSocket) { - webSockets.add(eventWebSocket); - } - - public void unregisterListener(EventWebSocket eventWebSocket) { - webSockets.remove(eventWebSocket); - } - - private class EventWebSocketCreator implements WebSocketCreator { - private static final String API_TOKEN_PREFIX = "oh."; - - private final Logger logger = LoggerFactory.getLogger(EventWebSocketCreator.class); - - @Override - public @Nullable Object createWebSocket(@Nullable ServletUpgradeRequest servletUpgradeRequest, - @Nullable ServletUpgradeResponse servletUpgradeResponse) { - if (servletUpgradeRequest == null) { - return null; - } - - Map> parameterMap = servletUpgradeRequest.getParameterMap(); - List accessToken = parameterMap.getOrDefault("accessToken", List.of()); - if (accessToken.size() == 1 && authenticateAccessToken(accessToken.get(0))) { - return new EventWebSocket(gson, EventWebSocketServlet.this, itemEventUtility, eventPublisher); - } else { - logger.warn("Unauthenticated request to create a websocket from {}.", - servletUpgradeRequest.getRemoteAddress()); - } - - return null; - } - - private boolean authenticateAccessToken(String token) { - Credentials credentials = null; - if (token.startsWith(API_TOKEN_PREFIX)) { - credentials = new UserApiTokenCredentials(token); - } else { - // try BasicAuthentication - String[] decodedParts = new String(Base64.getDecoder().decode(token)).split(":"); - if (decodedParts.length == 2) { - credentials = new UsernamePasswordCredentials(decodedParts[0], decodedParts[1]); - } - } - - if (credentials != null) { - try { - Authentication auth = userRegistry.authenticate(credentials); - User user = userRegistry.get(auth.getUsername()); - return user != null - && (user.getRoles().contains(Role.USER) || user.getRoles().contains(Role.ADMIN)); - } catch (AuthenticationException ignored) { - } - } - - return false; - } - } -} diff --git a/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/WebSocketAdapter.java b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/WebSocketAdapter.java new file mode 100644 index 00000000000..5007e1faf10 --- /dev/null +++ b/bundles/org.openhab.core.io.websocket/src/main/java/org/openhab/core/io/websocket/WebSocketAdapter.java @@ -0,0 +1,44 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.websocket; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; + +/** + * The {@link WebSocketAdapter} can be implemented to register an adapter for a websocket connection. + * It will be accessible on the path /ws/ADAPTER_ID of your server. + * Security is handled by the {@link CommonWebSocketServlet}. + * + * @author Miguel Álvarez Díez - Initial contribution + */ +@NonNullByDefault +public interface WebSocketAdapter { + /** + * The adapter id. + * In combination with the base path {@link CommonWebSocketServlet#SERVLET_PATH} defines the adapter path. + * + * @return the adapter id. + */ + String getId(); + + /** + * Creates a websocket instance. + * It should use the {@link org.eclipse.jetty.websocket.api.annotations} or implement + * {@link org.eclipse.jetty.websocket.api.WebSocketListener}. + * + * @return a websocket instance. + */ + Object createWebSocket(ServletUpgradeRequest servletUpgradeRequest, ServletUpgradeResponse servletUpgradeResponse); +} diff --git a/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/CommonWebSocketServletTest.java b/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/CommonWebSocketServletTest.java new file mode 100644 index 00000000000..17a1cd85fb0 --- /dev/null +++ b/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/CommonWebSocketServletTest.java @@ -0,0 +1,99 @@ +/** + * Copyright (c) 2010-2023 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.io.websocket; + +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.Mockito.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.HashMap; +import java.util.List; + +import javax.servlet.ServletException; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jetty.websocket.api.WebSocketPolicy; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeRequest; +import org.eclipse.jetty.websocket.servlet.ServletUpgradeResponse; +import org.eclipse.jetty.websocket.servlet.WebSocketCreator; +import org.eclipse.jetty.websocket.servlet.WebSocketServletFactory; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Captor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import org.openhab.core.auth.AuthenticationException; +import org.openhab.core.io.rest.auth.AnonymousUserSecurityContext; +import org.openhab.core.io.rest.auth.AuthFilter; +import org.osgi.service.http.NamespaceException; + +/** + * The {@link CommonWebSocketServletTest} contains tests for the {@link EventWebSocket} + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +public class CommonWebSocketServletTest { + private final String testAdapterId = "test-adapter-id"; + + private @NonNullByDefault({}) CommonWebSocketServlet servlet; + private @Mock @NonNullByDefault({}) AuthFilter authFilter; + private @Mock @NonNullByDefault({}) WebSocketServletFactory factory; + private @Mock @NonNullByDefault({}) WebSocketAdapter testDefaultWsAdapter; + private @Mock @NonNullByDefault({}) WebSocketAdapter testWsAdapter; + + private @Mock @NonNullByDefault({}) WebSocketPolicy wsPolicy; + private @Mock @NonNullByDefault({}) ServletUpgradeRequest request; + private @Mock @NonNullByDefault({}) ServletUpgradeResponse response; + private @Captor @NonNullByDefault({}) ArgumentCaptor webSocketCreatorAC; + + @BeforeEach + public void setup() throws ServletException, NamespaceException, AuthenticationException, IOException { + servlet = new CommonWebSocketServlet(authFilter); + when(factory.getPolicy()).thenReturn(wsPolicy); + servlet.configure(factory); + verify(factory).setCreator(webSocketCreatorAC.capture()); + var params = new HashMap>(); + when(request.getParameterMap()).thenReturn(params); + when(authFilter.getSecurityContext(any(), anyBoolean())).thenReturn(new AnonymousUserSecurityContext()); + when(testDefaultWsAdapter.getId()).thenReturn(CommonWebSocketServlet.DEFAULT_ADAPTER_ID); + when(testWsAdapter.getId()).thenReturn(testAdapterId); + servlet.addWebSocketAdapter(testDefaultWsAdapter); + servlet.addWebSocketAdapter(testWsAdapter); + } + + @Test + public void createWebsocketUsingDefaultAdapterPath() throws URISyntaxException { + when(request.getRequestURI()).thenReturn(new URI("http://127.0.0.1:8080/ws")); + webSocketCreatorAC.getValue().createWebSocket(request, response); + verify(testDefaultWsAdapter, times(1)).createWebSocket(request, response); + } + + @Test + public void createWebsocketUsingAdapterPath() throws URISyntaxException { + when(request.getRequestURI()).thenReturn(new URI("http://127.0.0.1:8080/ws/"+ testAdapterId)); + webSocketCreatorAC.getValue().createWebSocket(request, response); + verify(testWsAdapter, times(1)).createWebSocket(request, response); + } +} diff --git a/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/EventWebSocketTest.java b/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/EventWebSocketTest.java index 9c525b96788..49eb37596de 100644 --- a/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/EventWebSocketTest.java +++ b/bundles/org.openhab.core.io.websocket/src/test/java/org/openhab/core/io/websocket/EventWebSocketTest.java @@ -63,7 +63,7 @@ public class EventWebSocketTest { private Gson gson = new Gson(); - private @Mock @NonNullByDefault({}) EventWebSocketServlet servlet; + private @Mock @NonNullByDefault({}) EventWebSocketAdapter servlet; private @Mock @NonNullByDefault({}) ItemRegistry itemRegistry; private @Mock @NonNullByDefault({}) EventPublisher eventPublisher; private @Mock @NonNullByDefault({}) Session session; diff --git a/features/karaf/openhab-core/src/main/feature/feature.xml b/features/karaf/openhab-core/src/main/feature/feature.xml index 5fe065535e4..615a415ab7b 100644 --- a/features/karaf/openhab-core/src/main/feature/feature.xml +++ b/features/karaf/openhab-core/src/main/feature/feature.xml @@ -181,6 +181,8 @@ openhab-core-base + openhab-core-io-rest-auth + mvn:org.eclipse.jetty.websocket/websocket-servlet/${jetty.version} mvn:org.eclipse.jetty.websocket/websocket-server/${jetty.version} mvn:org.openhab.core.bundles/org.openhab.core.io.websocket/${project.version}