mirror of
https://github.com/danieldemus/openhab-addons
synced 2026-07-29 12:34:21 +02:00
[unifi] Fix session reauthentication for websockets (#21063)
* [unifi] Fixes session reauthentication for websockets When a protect or access websocket session expired, the client was not reauthorizing correctly with the new shared auth model. Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
+48
-2
@@ -42,9 +42,12 @@ import org.eclipse.jetty.http.HttpHeader;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.eclipse.jetty.websocket.api.Session;
|
||||
import org.eclipse.jetty.websocket.api.UpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.api.UpgradeResponse;
|
||||
import org.eclipse.jetty.websocket.api.WebSocketAdapter;
|
||||
import org.eclipse.jetty.websocket.client.ClientUpgradeRequest;
|
||||
import org.eclipse.jetty.websocket.client.WebSocketClient;
|
||||
import org.eclipse.jetty.websocket.client.io.UpgradeListener;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Device;
|
||||
import org.openhab.binding.unifi.internal.access.dto.DeviceAccessMethodSettings;
|
||||
import org.openhab.binding.unifi.internal.access.dto.Door;
|
||||
@@ -887,6 +890,12 @@ public final class UnifiAccessApiClient implements Closeable {
|
||||
// Seed the heartbeat timestamp so the monitor doesn't see the default 0 and immediately escalate
|
||||
// if its first tick races with onWebSocketConnect.
|
||||
this.lastHeartbeatEpochMs = System.currentTimeMillis();
|
||||
connectNotifications(onOpen, onMessage, onError, true);
|
||||
startWsMonitor();
|
||||
}
|
||||
|
||||
private void connectNotifications(Runnable onOpen, Consumer<Notification> onMessage, Consumer<Throwable> onError,
|
||||
boolean allowReauth) throws UnifiAccessApiException {
|
||||
try {
|
||||
URI wsUri = URI.create("wss://" + host + V2_BASE + "/ws/notification");
|
||||
logger.debug("Notifications WebSocket URI: {}", wsUri);
|
||||
@@ -960,13 +969,50 @@ public final class UnifiAccessApiClient implements Closeable {
|
||||
}
|
||||
};
|
||||
|
||||
wsClient.connect(socket, wsUri, req);
|
||||
startWsMonitor();
|
||||
// The upgrade HTTP status is delivered here directly; a 401 means an expired session cookie.
|
||||
UpgradeListener upgradeListener = new UpgradeListener() {
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public void onHandshakeRequest(UpgradeRequest request) {
|
||||
}
|
||||
|
||||
@Override
|
||||
@NonNullByDefault({})
|
||||
public void onHandshakeResponse(UpgradeResponse response) {
|
||||
int status = response.getStatusCode();
|
||||
logger.debug("Notifications WebSocket upgrade response: {}", status);
|
||||
if (allowReauth && status == HttpStatus.UNAUTHORIZED_401) {
|
||||
executorService.execute(() -> reauthenticateAndReconnect(onOpen, onMessage, onError));
|
||||
}
|
||||
}
|
||||
};
|
||||
wsClient.connect(socket, wsUri, req, upgradeListener);
|
||||
} catch (IOException e) {
|
||||
throw new UnifiAccessApiException("WebSocket connect failed: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
private void reauthenticateAndReconnect(Runnable onOpen, Consumer<Notification> onMessage,
|
||||
Consumer<Throwable> onError) {
|
||||
logger.debug("Notifications WebSocket upgrade rejected (401); re-authenticating and retrying");
|
||||
unifiSession.reauthenticate().whenComplete((v, ex) -> {
|
||||
if (ex != null) {
|
||||
logger.debug("Re-authentication after WebSocket 401 failed: {}", ex.getMessage());
|
||||
return;
|
||||
}
|
||||
synchronized (this) {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
connectNotifications(onOpen, onMessage, onError, false);
|
||||
} catch (UnifiAccessApiException e) {
|
||||
logger.debug("WebSocket reconnect after re-auth failed: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- HTTP Helpers ----
|
||||
|
||||
private ContentResponse execGet(String path) throws UnifiAccessApiException {
|
||||
|
||||
+13
-2
@@ -32,6 +32,8 @@ public class UniFiSessionImpl implements UniFiSession {
|
||||
private final String baseUrl;
|
||||
private final UniFiAuthenticator authenticator;
|
||||
private final UniFiRequestThrottler throttler;
|
||||
private final Object reauthLock = new Object();
|
||||
private @Nullable CompletableFuture<Void> reauthInFlight;
|
||||
|
||||
public UniFiSessionImpl(String baseUrl, UniFiAuthenticator authenticator, UniFiRequestThrottler throttler) {
|
||||
this.baseUrl = baseUrl;
|
||||
@@ -66,8 +68,17 @@ public class UniFiSessionImpl implements UniFiSession {
|
||||
|
||||
@Override
|
||||
public CompletableFuture<Void> reauthenticate() {
|
||||
authenticator.clearAuth();
|
||||
return authenticator.authenticate();
|
||||
// Don't clear credentials first: a failed/throttled login must leave the still-valid cookie in place.
|
||||
// Coalesce concurrent callers (Network/Protect/Access share this session).
|
||||
synchronized (reauthLock) {
|
||||
CompletableFuture<Void> existing = reauthInFlight;
|
||||
if (existing != null && !existing.isDone()) {
|
||||
return existing;
|
||||
}
|
||||
CompletableFuture<Void> future = authenticator.authenticate();
|
||||
reauthInFlight = future;
|
||||
return future;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
* Copyright (c) 2010-2026 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.binding.unifi.internal.api;
|
||||
|
||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||
import org.eclipse.jdt.annotation.Nullable;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.eclipse.jetty.websocket.api.UpgradeException;
|
||||
|
||||
/**
|
||||
* Helpers shared by the family WebSocket clients (UniFi Protect private API, UniFi Access notifications).
|
||||
*
|
||||
* @author Dan Cunningham - Initial contribution
|
||||
*/
|
||||
@NonNullByDefault
|
||||
public final class UniFiWebSocketUtil {
|
||||
|
||||
private UniFiWebSocketUtil() {
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {@code true} if the throwable chain represents a WebSocket upgrade rejected with HTTP 401.
|
||||
*/
|
||||
public static boolean isUnauthorizedUpgrade(@Nullable Throwable ex) {
|
||||
Throwable cause = ex;
|
||||
while (cause != null) {
|
||||
if (cause instanceof UpgradeException ue && ue.getResponseStatusCode() == HttpStatus.UNAUTHORIZED_401) {
|
||||
return true;
|
||||
}
|
||||
cause = cause.getCause();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
+6
@@ -146,4 +146,10 @@ public class UniFiProtectAuthenticator {
|
||||
this.userId = null;
|
||||
logger.debug("Cleared cached user id");
|
||||
}
|
||||
|
||||
public CompletableFuture<Void> reauthenticate() {
|
||||
this.userId = null;
|
||||
logger.debug("Re-authenticating shared UniFi session");
|
||||
return session.reauthenticate();
|
||||
}
|
||||
}
|
||||
|
||||
+58
-7
@@ -35,6 +35,7 @@ import org.eclipse.jetty.client.util.StringContentProvider;
|
||||
import org.eclipse.jetty.http.HttpMethod;
|
||||
import org.eclipse.jetty.http.HttpStatus;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiSession;
|
||||
import org.openhab.binding.unifi.internal.api.UniFiWebSocketUtil;
|
||||
import org.openhab.binding.unifi.internal.protect.api.priv.dto.devices.Camera;
|
||||
import org.openhab.binding.unifi.internal.protect.api.priv.dto.devices.Chime;
|
||||
import org.openhab.binding.unifi.internal.protect.api.priv.dto.devices.Doorlock;
|
||||
@@ -77,6 +78,7 @@ public class UniFiProtectPrivateClient {
|
||||
private volatile @Nullable UniFiProtectPrivateWebSocket webSocket;
|
||||
private volatile @Nullable ScheduledFuture<?> bootstrapRefreshTask;
|
||||
private volatile @Nullable CompletableFuture<Bootstrap> inFlightBootstrapRefresh;
|
||||
private @Nullable CompletableFuture<Void> reauthInFlight;
|
||||
|
||||
/**
|
||||
* Create a new UniFi Protect private-API client using a session supplied by the parent
|
||||
@@ -196,23 +198,46 @@ public class UniFiProtectPrivateClient {
|
||||
*/
|
||||
public CompletableFuture<Void> enableWebSocket(
|
||||
Consumer<UniFiProtectPrivateWebSocket.WebSocketUpdate> updateHandler) {
|
||||
return ensureAuthenticated().thenCompose(v -> {
|
||||
return ensureAuthenticated().thenCompose(v -> connectWebSocket(updateHandler, true));
|
||||
}
|
||||
|
||||
// A null cookie is fine: the shared HttpClient's CookieStore supplies it on the upgrade (as for REST), and a
|
||||
// genuine 401 is re-authenticated and retried below.
|
||||
private CompletableFuture<Void> connectWebSocket(
|
||||
Consumer<UniFiProtectPrivateWebSocket.WebSocketUpdate> updateHandler, boolean allowReauth) {
|
||||
if (webSocket != null) {
|
||||
logger.debug("WebSocket already enabled");
|
||||
return CompletableFuture.completedFuture(null);
|
||||
}
|
||||
|
||||
String wsUrl = baseUrl.replace("https://", "wss://").replace("http://", "ws://")
|
||||
+ "/proxy/protect/ws/updates";
|
||||
String wsUrl = baseUrl.replace("https://", "wss://").replace("http://", "ws://") + "/proxy/protect/ws/updates";
|
||||
String cookie = authenticator.getAuthCookie();
|
||||
if (cookie == null) {
|
||||
return CompletableFuture.failedFuture(new IllegalStateException("Not authenticated"));
|
||||
UniFiProtectPrivateWebSocket ws = new UniFiProtectPrivateWebSocket(wsUrl, cookie, updateHandler, this,
|
||||
httpClient);
|
||||
return ws.connect().handle((connected, ex) -> ex).thenCompose(ex -> {
|
||||
if (ex == null) {
|
||||
webSocket = ws;
|
||||
return CompletableFuture.<Void> completedFuture(null);
|
||||
}
|
||||
webSocket = new UniFiProtectPrivateWebSocket(wsUrl, cookie, updateHandler, this, httpClient);
|
||||
return webSocket.connect();
|
||||
ws.disconnect();
|
||||
if (allowReauth && UniFiWebSocketUtil.isUnauthorizedUpgrade(ex)) {
|
||||
logger.debug("WebSocket upgrade rejected (401); re-authenticating and retrying");
|
||||
return reauthenticateSession().thenCompose(v -> connectWebSocket(updateHandler, false));
|
||||
}
|
||||
return CompletableFuture.<Void> failedFuture(ex);
|
||||
});
|
||||
}
|
||||
|
||||
private synchronized CompletableFuture<Void> reauthenticateSession() {
|
||||
CompletableFuture<Void> existing = reauthInFlight;
|
||||
if (existing != null && !existing.isDone()) {
|
||||
return existing;
|
||||
}
|
||||
CompletableFuture<Void> future = authenticator.reauthenticate();
|
||||
reauthInFlight = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generic API request
|
||||
*/
|
||||
@@ -238,6 +263,12 @@ public class UniFiProtectPrivateClient {
|
||||
*/
|
||||
private <T> CompletableFuture<T> makeApiRequest(HttpMethod method, String path, @Nullable Object body,
|
||||
Class<T> responseType) {
|
||||
return makeApiRequest(method, path, body, responseType, true);
|
||||
}
|
||||
|
||||
// allowReauth=true: a 401 re-authenticates and retries once (the retry passes false to avoid looping).
|
||||
private <T> CompletableFuture<T> makeApiRequest(HttpMethod method, String path, @Nullable Object body,
|
||||
Class<T> responseType, boolean allowReauth) {
|
||||
return ensureAuthenticated().thenCompose(v -> {
|
||||
CompletableFuture<T> future = new CompletableFuture<>();
|
||||
|
||||
@@ -276,10 +307,30 @@ public class UniFiProtectPrivateClient {
|
||||
int status = response.getStatus();
|
||||
|
||||
if (status == HttpStatus.UNAUTHORIZED_401) {
|
||||
if (allowReauth) {
|
||||
logger.debug("Authentication rejected: {}, re-authenticating and retrying", status);
|
||||
reauthenticateSession().whenComplete((rv, rex) -> {
|
||||
if (rex != null) {
|
||||
authenticator.clearAuth();
|
||||
future.completeExceptionally(new AuthenticationException(
|
||||
"Re-authentication failed after " + status, rex));
|
||||
} else {
|
||||
makeApiRequest(method, path, body, responseType, false)
|
||||
.whenComplete((retryResult, retryEx) -> {
|
||||
if (retryEx != null) {
|
||||
future.completeExceptionally(retryEx);
|
||||
} else {
|
||||
future.complete(retryResult);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
logger.debug("Authentication rejected: {}", status);
|
||||
authenticator.clearAuth();
|
||||
future.completeExceptionally(
|
||||
new AuthenticationException("Authentication rejected: " + status));
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (status == HttpStatus.FORBIDDEN_403 || status == HttpStatus.TOO_MANY_REQUESTS_429) {
|
||||
|
||||
+3
-3
@@ -58,7 +58,7 @@ public class UniFiProtectPrivateWebSocket {
|
||||
private final Logger logger = LoggerFactory.getLogger(UniFiProtectPrivateWebSocket.class);
|
||||
|
||||
private final String wsUrl;
|
||||
private final String authCookie;
|
||||
private final @Nullable String authCookie;
|
||||
private final Consumer<WebSocketUpdate> updateHandler;
|
||||
private final UniFiProtectPrivateClient client;
|
||||
private final WebSocketClient wsClient;
|
||||
@@ -81,8 +81,8 @@ public class UniFiProtectPrivateWebSocket {
|
||||
* @param client Reference to the main client
|
||||
* @param httpClient HTTP client to use (shared from main client)
|
||||
*/
|
||||
public UniFiProtectPrivateWebSocket(String wsUrl, String authCookie, Consumer<WebSocketUpdate> updateHandler,
|
||||
UniFiProtectPrivateClient client, HttpClient httpClient) {
|
||||
public UniFiProtectPrivateWebSocket(String wsUrl, @Nullable String authCookie,
|
||||
Consumer<WebSocketUpdate> updateHandler, UniFiProtectPrivateClient client, HttpClient httpClient) {
|
||||
this.wsUrl = wsUrl;
|
||||
this.authCookie = authCookie;
|
||||
this.updateHandler = updateHandler;
|
||||
|
||||
+12
-7
@@ -298,10 +298,11 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
|
||||
inner = cause.getCause();
|
||||
}
|
||||
if (cause instanceof AuthenticationException) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
||||
"@text/offline.conf-error-auth-rejected");
|
||||
// Already survived an in-client re-auth; treat as a transient expired session and reconnect with a
|
||||
// status detail so the failure is visible rather than a bare OFFLINE.
|
||||
setOfflineAndReconnect(false, "@text/offline.comm-error-retrying");
|
||||
} else if (cause instanceof ThrottledException) {
|
||||
setOfflineAndReconnect(true);
|
||||
setOfflineAndReconnect(true, null);
|
||||
} else {
|
||||
// Public API 401 (bad token) arrives as IOException("HTTP 401: ...").
|
||||
if (cause instanceof IOException ioe) {
|
||||
@@ -310,7 +311,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
|
||||
attemptTokenRecovery();
|
||||
}
|
||||
}
|
||||
setOfflineAndReconnect(false);
|
||||
setOfflineAndReconnect(false, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -527,7 +528,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
|
||||
}
|
||||
}
|
||||
|
||||
private synchronized void setOfflineAndReconnect(boolean throttled) {
|
||||
private synchronized void setOfflineAndReconnect(boolean throttled, @Nullable String message) {
|
||||
ScheduledFuture<?> existing = this.reconnectTask;
|
||||
if (shuttingDown) {
|
||||
return;
|
||||
@@ -555,8 +556,12 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
|
||||
delay = Math.min((int) Math.pow(2, reconnectAttempt) * 5, MAX_RECONNECT_DELAY_SECONDS);
|
||||
reconnectAttempt++;
|
||||
logger.debug("Scheduling reconnect in {} seconds (attempt {})", delay, reconnectAttempt);
|
||||
if (message != null) {
|
||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, message);
|
||||
} else {
|
||||
updateStatus(ThingStatus.OFFLINE);
|
||||
}
|
||||
}
|
||||
|
||||
this.reconnectTask = scheduler.schedule(this::initialize, delay, TimeUnit.SECONDS);
|
||||
}
|
||||
@@ -574,7 +579,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
|
||||
scheduler.execute(() -> syncDevices());
|
||||
}, (code, reason) -> {
|
||||
logger.debug("Event WS closed: {} {}", code, reason);
|
||||
setOfflineAndReconnect(false);
|
||||
setOfflineAndReconnect(false, null);
|
||||
}, err -> logger.debug("Event WS error", err)).get();
|
||||
return;
|
||||
} catch (ExecutionException e) {
|
||||
@@ -636,7 +641,7 @@ public class UnifiProtectNVRHandler extends BaseBridgeHandler {
|
||||
// ignore on-open
|
||||
}, (code, reason) -> {
|
||||
logger.debug("Device WS closed: {} {}", code, reason);
|
||||
setOfflineAndReconnect(false);
|
||||
setOfflineAndReconnect(false, null);
|
||||
}, err -> logger.debug("Device WS error", err)).get();
|
||||
return;
|
||||
} catch (ExecutionException e) {
|
||||
|
||||
+1
@@ -421,6 +421,7 @@ offline.conf-error-no-user-id = Failed to retrieve user ID from UniFi Protect
|
||||
offline.conf-error-api-key-empty = API key creation returned empty result
|
||||
offline.conf-error-api-key-creation = Failed to create API key
|
||||
offline.comm-error-refresh-failed = Failed to refresh device from API
|
||||
offline.comm-error-retrying = Authentication failed, retrying
|
||||
offline.conf-error-auth-rejected = Authentication failed - check username and password
|
||||
offline.gone = Device no longer found on NVR
|
||||
offline.login-throttled = Login throttled by UniFi Protect controller
|
||||
|
||||
Reference in New Issue
Block a user