mirror of
https://github.com/danieldemus/openhab-core.git
synced 2025-01-10 21:31:53 +01:00
[websocket] Allow registering websocket adapters (#3622)
* [WebSocket] Allow register websocket handlers Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
This commit is contained in:
parent
be7488958d
commit
e3396c9477
@ -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;
|
||||
|
@ -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.ConfigParser;
|
||||
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 @@ import org.slf4j.LoggerFactory;
|
||||
* @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 @@ public class AuthFilter implements ContainerRequestFilter {
|
||||
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;
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (isImplicitUserRole(requestContext)) {
|
||||
requestContext.setSecurityContext(new AnonymousUserSecurityContext());
|
||||
SecurityContext sc = getSecurityContext(servletRequest, false);
|
||||
if (sc != null) {
|
||||
requestContext.setSecurityContext(sc);
|
||||
}
|
||||
} catch (AuthenticationException e) {
|
||||
logger.warn("Unauthorized API request from {}: {}", getClientIp(requestContext), e.getMessage());
|
||||
logger.warn("Unauthorized API request from {}: {}", getClientIp(servletRequest), e.getMessage());
|
||||
requestContext.abortWith(JSONResponse.createErrorResponse(Status.UNAUTHORIZED, "Invalid credentials"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isImplicitUserRole(ContainerRequestContext requestContext) {
|
||||
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<String, String[]> 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 ("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(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 @@ public class AuthFilter implements ContainerRequestFilter {
|
||||
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;
|
||||
}
|
@ -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 @@ public class ExpiringUserSecurityContextCache {
|
||||
};
|
||||
}
|
||||
|
||||
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 @@ public class ExpiringUserSecurityContextCache {
|
||||
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();
|
||||
}
|
||||
|
||||
|
@ -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.MockitoExtension;
|
||||
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 class AuthFilterTest {
|
||||
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 class AuthFilterTest {
|
||||
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());
|
@ -20,6 +20,11 @@
|
||||
<artifactId>org.openhab.core</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.openhab.core.bundles</groupId>
|
||||
<artifactId>org.openhab.core.io.rest.auth</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
@ -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<String, WebSocketAdapter> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -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<String> typeFilter = List.of();
|
||||
private List<String> 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 class EventWebSocket {
|
||||
|
||||
@OnWebSocketClose
|
||||
public void onClose(int statusCode, String reason) {
|
||||
this.servlet.unregisterListener(this);
|
||||
this.wsAdapter.unregisterListener(this);
|
||||
remoteIdentifier = "<unknown>";
|
||||
this.session = null;
|
||||
this.remoteEndpoint = null;
|
||||
@ -86,7 +86,7 @@ public class EventWebSocket {
|
||||
RemoteEndpoint remoteEndpoint = session.getRemote();
|
||||
this.remoteEndpoint = remoteEndpoint;
|
||||
this.remoteIdentifier = remoteEndpoint.getInetSocketAddress().toString();
|
||||
this.servlet.registerListener(this);
|
||||
this.wsAdapter.registerListener(this);
|
||||
}
|
||||
|
||||
@OnWebSocketMessage
|
||||
|
@ -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<EventWebSocket> webSockets = new CopyOnWriteArraySet<>();
|
||||
|
||||
@Activate
|
||||
public EventWebSocketAdapter(@Reference EventPublisher eventPublisher, @Reference ItemRegistry itemRegistry) {
|
||||
this.eventPublisher = eventPublisher;
|
||||
itemEventUtility = new ItemEventUtility(gson, itemRegistry);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> 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);
|
||||
}
|
||||
}
|
@ -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<EventWebSocket> 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<String> 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<String, List<String>> parameterMap = servletUpgradeRequest.getParameterMap();
|
||||
List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
@ -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);
|
||||
}
|
@ -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<WebSocketCreator> 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<String, List<String>>();
|
||||
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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -181,6 +181,8 @@
|
||||
|
||||
<feature name="openhab-core-io-websocket" version="${project.version}">
|
||||
<feature>openhab-core-base</feature>
|
||||
<feature dependency="true">openhab-core-io-rest-auth</feature>
|
||||
|
||||
<bundle>mvn:org.eclipse.jetty.websocket/websocket-servlet/${jetty.version}</bundle>
|
||||
<bundle>mvn:org.eclipse.jetty.websocket/websocket-server/${jetty.version}</bundle>
|
||||
<bundle>mvn:org.openhab.core.bundles/org.openhab.core.io.websocket/${project.version}</bundle>
|
||||
|
Loading…
Reference in New Issue
Block a user