[Netatmo] Modification of the tokenRefresh handling process (#14548)

* Modification of the tokenRefresh handling process
* Storing refreshToken in userdata/netatmo

---------

Signed-off-by: clinique <gael@lhopital.org>
This commit is contained in:
Gaël L'hopital 2023-03-10 10:18:30 +01:00 committed by GitHub
parent 013422af32
commit d130595f85
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 102 additions and 81 deletions

View 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:

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

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

View 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()) {

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

View File

@ -17,13 +17,6 @@
<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>

View 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