[websocket] Allow registering websocket adapters (#3622)

* [WebSocket] Allow register websocket handlers

Signed-off-by: Miguel Álvarez <miguelwork92@gmail.com>
This commit is contained in:
GiviMAD 2023-06-10 15:35:23 +02:00 committed by GitHub
parent be7488958d
commit e3396c9477
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 450 additions and 217 deletions

View File

@ -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;

View File

@ -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;
}

View File

@ -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();
}

View File

@ -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());

View File

@ -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>

View File

@ -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;
}
}
}
}

View File

@ -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

View File

@ -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);
}
}

View File

@ -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;
}
}
}

View File

@ -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);
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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>