From d130595f85f96dedd2678092d40a9b22222c3537 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ga=C3=ABl=20L=27hopital?= Date: Fri, 10 Mar 2023 10:18:30 +0100 Subject: [PATCH] [Netatmo] Modification of the tokenRefresh handling process (#14548) * Modification of the tokenRefresh handling process * Storing refreshToken in userdata/netatmo --------- Signed-off-by: clinique --- bundles/org.openhab.binding.netatmo/README.md | 6 +- .../internal/api/AuthenticationApi.java | 59 +++++----- .../netatmo/internal/api/RestManager.java | 2 +- .../config/ApiHandlerConfiguration.java | 4 +- .../internal/handler/ApiBridgeHandler.java | 103 +++++++++++------- .../main/resources/OH-INF/config/config.xml | 7 -- .../resources/OH-INF/i18n/netatmo.properties | 2 - 7 files changed, 102 insertions(+), 81 deletions(-) diff --git a/bundles/org.openhab.binding.netatmo/README.md b/bundles/org.openhab.binding.netatmo/README.md index a2151ff80f3..345b2665b48 100644 --- a/bundles/org.openhab.binding.netatmo/README.md +++ b/bundles/org.openhab.binding.netatmo/README.md @@ -50,9 +50,6 @@ The Account bridge has the following configuration elements: | webHookUrl | String | No | Protocol, public IP and port to access openHAB server from Internet | | webHookPostfix | String | No | String appended to the generated webhook address (should start with "/") | | reconnectInterval | Number | No | The reconnection interval to Netatmo API (in s) | -| refreshToken | String | Yes* | The refresh token provided by Netatmo API after the granting process. Can be saved in case of file based configuration | - -(*) Strictly said this parameter is not mandatory at first run, until you grant your binding on Netatmo Connect. Once present, you'll not have to grant again. **Supported channels for the Account bridge thing:** @@ -69,7 +66,6 @@ The Account bridge has the following configuration elements: 1. Go to the authorization page of your server. `http://:8080/netatmo/connect/<_CLIENT_ID_>`. Your newly added bridge should be listed there (no need for you to expose your openHAB server outside your local network for this). 1. Press the _"Authorize Thing"_ button. This will take you either to the login page of Netatmo Connect or directly to the authorization screen. Login and/or authorize the application. You will be returned and the entry should go green. 1. The bridge configuration will be updated with a refresh token and go _ONLINE_. The refresh token is used to re-authorize the bridge with Netatmo Connect Web API whenever required. So you can consult this token by opening the Thing page in MainUI, this is the value of the advanced parameter named “Refresh Token”. -1. If you're using file based .things config file, copy the provided refresh token in the **refreshToken** parameter of your thing definition (example below). Now that you have got your bridge _ONLINE_ you can now start a scan with the binding to auto discover your things. @@ -666,7 +662,7 @@ All these channels are read only. ### things/netatmo.things ```java -Bridge netatmo:account:myaccount "Netatmo Account" [clientId="xxxxx", clientSecret="yyyy", refreshToken="zzzzz"] { +Bridge netatmo:account:myaccount "Netatmo Account" [clientId="xxxxx", clientSecret="yyyy"] { Bridge weather-station inside "Inside Weather Station" [id="70:ee:aa:aa:aa:aa"] { outdoor outside "Outside Module" [id="02:00:00:aa:aa:aa"] { Channels: diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java index 91cc30c208e..0d174aecf55 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java @@ -17,6 +17,7 @@ import static org.openhab.core.auth.oauth2client.internal.Keyword.*; import java.net.URI; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; import java.util.Set; @@ -43,80 +44,86 @@ import org.slf4j.LoggerFactory; */ @NonNullByDefault public class AuthenticationApi extends RestManager { - private static final UriBuilder AUTH_BUILDER = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE); private static final URI TOKEN_URI = getApiBaseBuilder(PATH_OAUTH, SUB_PATH_TOKEN).build(); private final Logger logger = LoggerFactory.getLogger(AuthenticationApi.class); private final ScheduledExecutorService scheduler; private Optional> refreshTokenJob = Optional.empty(); - private Optional tokenResponse = Optional.empty(); + private List grantedScope = List.of(); + private @Nullable String authorization; public AuthenticationApi(ApiBridgeHandler bridge, ScheduledExecutorService scheduler) { super(bridge, FeatureArea.NONE); this.scheduler = scheduler; } - public String authorize(ApiHandlerConfiguration credentials, @Nullable String code, @Nullable String redirectUri) - throws NetatmoException { + public void authorize(ApiHandlerConfiguration credentials, String refreshToken, @Nullable String code, + @Nullable String redirectUri) throws NetatmoException { if (!(credentials.clientId.isBlank() || credentials.clientSecret.isBlank())) { Map params = new HashMap<>(Map.of(SCOPE, FeatureArea.ALL_SCOPES)); - String refreshToken = credentials.refreshToken; + if (!refreshToken.isBlank()) { params.put(REFRESH_TOKEN, refreshToken); - } else { - if (code != null && redirectUri != null) { - params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code)); - } + } else if (code != null && redirectUri != null) { + params.putAll(Map.of(REDIRECT_URI, redirectUri, CODE, code)); } + if (params.size() > 1) { - return requestToken(credentials.clientId, credentials.clientSecret, params); + requestToken(credentials.clientId, credentials.clientSecret, params); + return; } } throw new IllegalArgumentException("Inconsistent configuration state, please file a bug report."); } - private String requestToken(String id, String secret, Map entries) throws NetatmoException { - Map payload = new HashMap<>(entries); - payload.put(GRANT_TYPE, payload.keySet().contains(CODE) ? AUTHORIZATION_CODE : REFRESH_TOKEN); - payload.putAll(Map.of(CLIENT_ID, id, CLIENT_SECRET, secret)); + private void requestToken(String clientId, String secret, Map entries) throws NetatmoException { disconnect(); + + Map payload = new HashMap<>(entries); + payload.putAll(Map.of(GRANT_TYPE, payload.keySet().contains(CODE) ? AUTHORIZATION_CODE : REFRESH_TOKEN, + CLIENT_ID, clientId, CLIENT_SECRET, secret)); + AccessTokenResponse response = post(TOKEN_URI, AccessTokenResponse.class, payload); + refreshTokenJob = Optional.of(scheduler.schedule(() -> { try { - requestToken(id, secret, Map.of(REFRESH_TOKEN, response.getRefreshToken())); + requestToken(clientId, secret, Map.of(REFRESH_TOKEN, response.getRefreshToken())); } catch (NetatmoException e) { logger.warn("Unable to refresh access token : {}", e.getMessage()); } - }, Math.round(response.getExpiresIn() * 0.8), TimeUnit.SECONDS)); - tokenResponse = Optional.of(response); - return response.getRefreshToken(); + }, Math.round(response.getExpiresIn() * 0.9), TimeUnit.SECONDS)); + + grantedScope = response.getScope(); + authorization = "Bearer %s".formatted(response.getAccessToken()); + apiBridge.storeRefreshToken(response.getRefreshToken()); } public void disconnect() { - tokenResponse = Optional.empty(); + authorization = null; + grantedScope = List.of(); } public void dispose() { + disconnect(); refreshTokenJob.ifPresent(job -> job.cancel(true)); refreshTokenJob = Optional.empty(); } - public @Nullable String getAuthorization() { - return tokenResponse.map(at -> String.format("Bearer %s", at.getAccessToken())).orElse(null); + public Optional getAuthorization() { + return Optional.ofNullable(authorization); } public boolean matchesScopes(Set requiredScopes) { - return requiredScopes.isEmpty() // either we do not require any scope, either connected and all scopes available - || (isConnected() && tokenResponse.map(at -> at.getScope().containsAll(requiredScopes)).orElse(false)); + return requiredScopes.isEmpty() || grantedScope.containsAll(requiredScopes); } public boolean isConnected() { - return tokenResponse.isPresent(); + return authorization != null; } public static UriBuilder getAuthorizationBuilder(String clientId) { - return AUTH_BUILDER.clone().queryParam(CLIENT_ID, clientId).queryParam(SCOPE, FeatureArea.ALL_SCOPES) - .queryParam(STATE, clientId); + return getApiBaseBuilder(PATH_OAUTH, SUB_PATH_AUTHORIZE).queryParam(CLIENT_ID, clientId) + .queryParam(SCOPE, FeatureArea.ALL_SCOPES).queryParam(STATE, clientId); } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/RestManager.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/RestManager.java index 398265f412d..2cd431e4772 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/RestManager.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/RestManager.java @@ -40,7 +40,7 @@ public abstract class RestManager { private static final UriBuilder API_URI_BUILDER = getApiBaseBuilder(PATH_API); private final Set requiredScopes; - private final ApiBridgeHandler apiBridge; + protected final ApiBridgeHandler apiBridge; public RestManager(ApiBridgeHandler apiBridge, FeatureArea features) { this.requiredScopes = features.scopes; diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java index ed2de2a5acf..71230d521a4 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java @@ -23,16 +23,14 @@ import org.eclipse.jdt.annotation.NonNullByDefault; @NonNullByDefault public class ApiHandlerConfiguration { public static final String CLIENT_ID = "clientId"; - public static final String REFRESH_TOKEN = "refreshToken"; public String clientId = ""; public String clientSecret = ""; - public String refreshToken = ""; public String webHookUrl = ""; public String webHookPostfix = ""; public int reconnectInterval = 300; - public ConfigurationLevel check() { + public ConfigurationLevel check(String refreshToken) { if (clientId.isBlank()) { return ConfigurationLevel.EMPTY_CLIENT_ID; } else if (clientSecret.isBlank()) { diff --git a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java index d210d91de3b..6e8da662910 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java +++ b/bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java @@ -16,10 +16,14 @@ import static java.util.Comparator.*; import static org.openhab.binding.netatmo.internal.NetatmoBindingConstants.*; import java.io.ByteArrayInputStream; +import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Constructor; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; import java.time.LocalDateTime; import java.util.ArrayDeque; import java.util.Collection; @@ -70,7 +74,7 @@ import org.openhab.binding.netatmo.internal.deserialization.NADeserializer; import org.openhab.binding.netatmo.internal.discovery.NetatmoDiscoveryService; import org.openhab.binding.netatmo.internal.servlet.GrantServlet; import org.openhab.binding.netatmo.internal.servlet.WebhookServlet; -import org.openhab.core.config.core.Configuration; +import org.openhab.core.OpenHAB; import org.openhab.core.library.types.DecimalType; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.ChannelUID; @@ -94,46 +98,56 @@ import org.slf4j.LoggerFactory; @NonNullByDefault public class ApiBridgeHandler extends BaseBridgeHandler { private static final int TIMEOUT_S = 20; + private static final String REFRESH_TOKEN = "refreshToken"; private final Logger logger = LoggerFactory.getLogger(ApiBridgeHandler.class); + private final AuthenticationApi connectApi = new AuthenticationApi(this, scheduler); + private final Map, RestManager> managers = new HashMap<>(); + private final Deque requestsTimestamps = new ArrayDeque<>(200); private final BindingConfiguration bindingConf; - private final AuthenticationApi connectApi; private final HttpClient httpClient; private final NADeserializer deserializer; private final HttpService httpService; + private final ChannelUID requestCountChannelUID; + private final Path tokenFile; private Optional> connectJob = Optional.empty(); - private Map, RestManager> managers = new HashMap<>(); - private @Nullable WebhookServlet webHookServlet; - private @Nullable GrantServlet grantServlet; - private Deque requestsTimestamps; - private final ChannelUID requestCountChannelUID; + private Optional webHookServlet = Optional.empty(); + private Optional grantServlet = Optional.empty(); public ApiBridgeHandler(Bridge bridge, HttpClient httpClient, NADeserializer deserializer, BindingConfiguration configuration, HttpService httpService) { super(bridge); this.bindingConf = configuration; - this.connectApi = new AuthenticationApi(this, scheduler); this.httpClient = httpClient; this.deserializer = deserializer; this.httpService = httpService; - this.requestsTimestamps = new ArrayDeque<>(200); - this.requestCountChannelUID = new ChannelUID(getThing().getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT); + this.requestCountChannelUID = new ChannelUID(thing.getUID(), GROUP_MONITORING, CHANNEL_REQUEST_COUNT); + + Path homeFolder = Paths.get(OpenHAB.getUserDataFolder(), BINDING_ID); + if (Files.notExists(homeFolder)) { + try { + Files.createDirectory(homeFolder); + } catch (IOException e) { + logger.warn("Unable to create {} folder : {}", homeFolder.toString(), e.getMessage()); + } + } + tokenFile = homeFolder.resolve(REFRESH_TOKEN + "_" + thing.getUID().toString().replace(":", "_")); } @Override public void initialize() { logger.debug("Initializing Netatmo API bridge handler."); updateStatus(ThingStatus.UNKNOWN); - GrantServlet servlet = new GrantServlet(this, httpService); - servlet.startListening(); - grantServlet = servlet; scheduler.execute(() -> openConnection(null, null)); } public void openConnection(@Nullable String code, @Nullable String redirectUri) { ApiHandlerConfiguration configuration = getConfiguration(); - ConfigurationLevel level = configuration.check(); + + String refreshToken = readRefreshToken(); + + ConfigurationLevel level = configuration.check(refreshToken); switch (level) { case EMPTY_CLIENT_ID: case EMPTY_CLIENT_SECRET: @@ -141,6 +155,9 @@ public class ApiBridgeHandler extends BaseBridgeHandler { break; case REFRESH_TOKEN_NEEDED: if (code == null || redirectUri == null) { + GrantServlet servlet = new GrantServlet(this, httpService); + servlet.startListening(); + grantServlet = Optional.of(servlet); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, level.message); break; } // else we can proceed to get the token refresh @@ -148,15 +165,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler { try { logger.debug("Connecting to Netatmo API."); - String refreshToken = connectApi.authorize(configuration, code, redirectUri); - - if (configuration.refreshToken.isBlank()) { - logger.trace("Adding refresh token to configuration : {}", refreshToken); - Configuration thingConfig = editConfiguration(); - thingConfig.put(ApiHandlerConfiguration.REFRESH_TOKEN, refreshToken); - updateConfiguration(thingConfig); - configuration = getConfiguration(); - } + connectApi.authorize(configuration, refreshToken, code, redirectUri); if (!configuration.webHookUrl.isBlank()) { SecurityApi securityApi = getRestManager(SecurityApi.class); @@ -164,7 +173,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler { WebhookServlet servlet = new WebhookServlet(this, httpService, deserializer, securityApi, configuration.webHookUrl, configuration.webHookPostfix); servlet.startListening(); - this.webHookServlet = servlet; + this.webHookServlet = Optional.of(servlet); } } @@ -182,6 +191,30 @@ public class ApiBridgeHandler extends BaseBridgeHandler { } } + private String readRefreshToken() { + if (Files.exists(tokenFile)) { + try { + return Files.readString(tokenFile); + } catch (IOException e) { + logger.warn("Unable to read token file {} : {}", tokenFile.toString(), e.getMessage()); + } + } + return ""; + } + + public void storeRefreshToken(String refreshToken) { + if (refreshToken.isBlank()) { + logger.trace("Blank refresh token received - ignored"); + } else { + logger.trace("Updating refresh token in {} : {}", tokenFile.toString(), refreshToken); + try { + Files.write(tokenFile, refreshToken.getBytes()); + } catch (IOException e) { + logger.warn("Error saving refresh token to {} : {}", tokenFile.toString(), e.getMessage()); + } + } + } + public ApiHandlerConfiguration getConfiguration() { return getConfigAs(ApiHandlerConfiguration.class); } @@ -201,14 +234,13 @@ public class ApiBridgeHandler extends BaseBridgeHandler { @Override public void dispose() { logger.debug("Shutting down Netatmo API bridge handler."); - WebhookServlet localWebHook = this.webHookServlet; - if (localWebHook != null) { - localWebHook.dispose(); - } - GrantServlet localGrant = this.grantServlet; - if (localGrant != null) { - localGrant.dispose(); - } + + webHookServlet.ifPresent(servlet -> servlet.dispose()); + webHookServlet = Optional.empty(); + + grantServlet.ifPresent(servlet -> servlet.dispose()); + grantServlet = Optional.empty(); + connectApi.dispose(); freeConnectJob(); super.dispose(); @@ -245,10 +277,7 @@ public class ApiBridgeHandler extends BaseBridgeHandler { Request request = httpClient.newRequest(uri).method(method).timeout(TIMEOUT_S, TimeUnit.SECONDS); - String auth = connectApi.getAuthorization(); - if (auth != null) { - request.header(HttpHeader.AUTHORIZATION, auth); - } + connectApi.getAuthorization().ifPresent(auth -> request.header(HttpHeader.AUTHORIZATION, auth)); if (payload != null && contentType != null && (HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method))) { @@ -390,6 +419,6 @@ public class ApiBridgeHandler extends BaseBridgeHandler { } public Optional getWebHookServlet() { - return Optional.ofNullable(webHookServlet); + return webHookServlet; } } diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml index 274d2e91073..6aa4018c016 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml @@ -17,13 +17,6 @@ password - - - @text/config.refreshToken.description - password - true - - @text/config.webHookUrl.description diff --git a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties index aa6e856286d..afe1576dac7 100644 --- a/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties +++ b/bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties @@ -427,8 +427,6 @@ config.clientId.label = Client ID config.clientId.description = Client ID provided for the application you created on http://dev.netatmo.com/createapp config.clientSecret.label = Client Secret config.clientSecret.description = Client Secret provided for the application you created. -config.refreshToken.label = Refresh Token -config.refreshToken.description = Refresh token provided by the oAuth2 authentication process. config.webHookPostfix.label = Webhook Postfix config.webHookPostfix.description = String appended to the generated webhook address (should start with `/`). config.webHookUrl.label = Webhook Address