[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
7 changed files with 102 additions and 81 deletions

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