]> git.basschouten.com Git - openhab-addons.git/commitdiff
[Netatmo] Modification of the tokenRefresh handling process (#14548)
authorGaël L'hopital <gael@lhopital.org>
Fri, 10 Mar 2023 09:18:30 +0000 (10:18 +0100)
committerGitHub <noreply@github.com>
Fri, 10 Mar 2023 09:18:30 +0000 (10:18 +0100)
* Modification of the tokenRefresh handling process
* Storing refreshToken in userdata/netatmo

---------

Signed-off-by: clinique <gael@lhopital.org>
bundles/org.openhab.binding.netatmo/README.md
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/AuthenticationApi.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/api/RestManager.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/config/ApiHandlerConfiguration.java
bundles/org.openhab.binding.netatmo/src/main/java/org/openhab/binding/netatmo/internal/handler/ApiBridgeHandler.java
bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/config/config.xml
bundles/org.openhab.binding.netatmo/src/main/resources/OH-INF/i18n/netatmo.properties

index a2151ff80f3258c68bd72d51e49fb83c686503b0..345b2665b48b410f428c70853293fd40df243e0b 100644 (file)
@@ -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://<your openHAB address>: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:
index 91cc30c208e2c4d9004390261a380328517319c9..0d174aecf550588ce8f1b3fe1e6c6db9fcdbb519 100644 (file)
@@ -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<ScheduledFuture<?>> refreshTokenJob = Optional.empty();
-    private Optional<AccessTokenResponse> tokenResponse = Optional.empty();
+    private List<Scope> 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<String, String> 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<String, String> entries) throws NetatmoException {
-        Map<String, String> 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<String, String> entries) throws NetatmoException {
         disconnect();
+
+        Map<String, String> 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<String> getAuthorization() {
+        return Optional.ofNullable(authorization);
     }
 
     public boolean matchesScopes(Set<Scope> 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);
     }
 }
index 398265f412d88c81a33212bf5da22b89aa4f7d1b..2cd431e4772afc8b4f7df6e41153473b9462db08 100644 (file)
@@ -40,7 +40,7 @@ public abstract class RestManager {
     private static final UriBuilder API_URI_BUILDER = getApiBaseBuilder(PATH_API);
 
     private final Set<Scope> requiredScopes;
-    private final ApiBridgeHandler apiBridge;
+    protected final ApiBridgeHandler apiBridge;
 
     public RestManager(ApiBridgeHandler apiBridge, FeatureArea features) {
         this.requiredScopes = features.scopes;
index ed2de2a5acf02e365a01d710979df4944724412f..71230d521a44e4e18fa281842859c89281507e34 100644 (file)
@@ -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()) {
index d210d91de3bca32d92bad03ca30be77a2fe134db..6e8da662910ca0745a43d694e3b8309bbcf19970 100644 (file)
@@ -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<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
+    private final Deque<LocalDateTime> 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<ScheduledFuture<?>> connectJob = Optional.empty();
-    private Map<Class<? extends RestManager>, RestManager> managers = new HashMap<>();
-    private @Nullable WebhookServlet webHookServlet;
-    private @Nullable GrantServlet grantServlet;
-    private Deque<LocalDateTime> requestsTimestamps;
-    private final ChannelUID requestCountChannelUID;
+    private Optional<WebhookServlet> webHookServlet = Optional.empty();
+    private Optional<GrantServlet> 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<WebhookServlet> getWebHookServlet() {
-        return Optional.ofNullable(webHookServlet);
+        return webHookServlet;
     }
 }
index 274d2e91073b8659106ba2b7c9a44bcfbcd1ad59..6aa4018c0166197151040977c08bbb37100cd445 100644 (file)
                        <context>password</context>
                </parameter>
 
-               <parameter name="refreshToken" type="text">
-                       <label>@text/config.refreshToken.label</label>
-                       <description>@text/config.refreshToken.description</description>
-                       <context>password</context>
-                       <advanced>true</advanced>
-               </parameter>
-
                <parameter name="webHookUrl" type="text" required="false">
                        <label>@text/config.webHookUrl.label</label>
                        <description>@text/config.webHookUrl.description</description>
index aa6e856286de425c8d49268c1fc2805d6e9b6d45..afe1576dac7ec0e59289dbdb2037a3a5be81f498 100644 (file)
@@ -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