[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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user