diff --git a/bundles/org.openhab.binding.myq/NOTICE b/bundles/org.openhab.binding.myq/NOTICE
index 38d625e34923..0ca708bef198 100644
--- a/bundles/org.openhab.binding.myq/NOTICE
+++ b/bundles/org.openhab.binding.myq/NOTICE
@@ -11,3 +11,10 @@ https://www.eclipse.org/legal/epl-2.0/.
== Source Code
https://github.com/openhab/openhab-addons
+
+== Third-party Content
+
+jsoup
+* License: MIT License
+* Project: https://jsoup.org/
+* Source: https://github.com/jhy/jsoup
diff --git a/bundles/org.openhab.binding.myq/pom.xml b/bundles/org.openhab.binding.myq/pom.xml
index 564dd9ca217c..a94ba5262dcb 100644
--- a/bundles/org.openhab.binding.myq/pom.xml
+++ b/bundles/org.openhab.binding.myq/pom.xml
@@ -14,4 +14,12 @@
openHAB Add-ons :: Bundles :: MyQ Binding
+
+
+ org.jsoup
+ jsoup
+ 1.8.3
+ provided
+
+
diff --git a/bundles/org.openhab.binding.myq/src/main/feature/feature.xml b/bundles/org.openhab.binding.myq/src/main/feature/feature.xml
index 60382373bb63..c5fabb87e6b1 100644
--- a/bundles/org.openhab.binding.myq/src/main/feature/feature.xml
+++ b/bundles/org.openhab.binding.myq/src/main/feature/feature.xml
@@ -4,6 +4,7 @@
openhab-runtime-base
+ mvn:org.jsoup/jsoup/1.8.3
mvn:org.openhab.addons.bundles/org.openhab.binding.myq/${project.version}
diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java
index 2d01eee7788a..d3d151cbec59 100644
--- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java
+++ b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java
@@ -20,6 +20,7 @@
import org.openhab.binding.myq.internal.handler.MyQAccountHandler;
import org.openhab.binding.myq.internal.handler.MyQGarageDoorHandler;
import org.openhab.binding.myq.internal.handler.MyQLampHandler;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
import org.openhab.core.io.net.http.HttpClientFactory;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.Thing;
@@ -41,10 +42,13 @@
@Component(configurationPid = "binding.myq", service = ThingHandlerFactory.class)
public class MyQHandlerFactory extends BaseThingHandlerFactory {
private final HttpClient httpClient;
+ private OAuthFactory oAuthFactory;
@Activate
- public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory) {
+ public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
+ final @Reference OAuthFactory oAuthFactory) {
this.httpClient = httpClientFactory.getCommonHttpClient();
+ this.oAuthFactory = oAuthFactory;
}
@Override
@@ -57,7 +61,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
- return new MyQAccountHandler((Bridge) thing, httpClient);
+ return new MyQAccountHandler((Bridge) thing, httpClient, oAuthFactory);
}
if (THING_TYPE_GARAGEDOOR.equals(thingTypeUID)) {
diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java
deleted file mode 100644
index 8b2eaf54014c..000000000000
--- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java
+++ /dev/null
@@ -1,30 +0,0 @@
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.myq.internal.dto;
-
-/**
- * The {@link LoginRequestDTO} entity from the MyQ API
- *
- * @author Dan Cunningham - Initial contribution
- */
-public class LoginRequestDTO {
-
- public LoginRequestDTO(String username, String password) {
- super();
- this.username = username;
- this.password = password;
- }
-
- public String username;
- public String password;
-}
diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java
deleted file mode 100644
index 2dfcd637d21b..000000000000
--- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java
+++ /dev/null
@@ -1,22 +0,0 @@
-/**
- * Copyright (c) 2010-2021 Contributors to the openHAB project
- *
- * See the NOTICE file(s) distributed with this work for additional
- * information.
- *
- * This program and the accompanying materials are made available under the
- * terms of the Eclipse Public License 2.0 which is available at
- * http://www.eclipse.org/legal/epl-2.0
- *
- * SPDX-License-Identifier: EPL-2.0
- */
-package org.openhab.binding.myq.internal.dto;
-
-/**
- * The {@link LoginResponseDTO} entity from the MyQ API
- *
- * @author Dan Cunningham - Initial contribution
- */
-public class LoginResponseDTO {
- public String securityToken;
-}
diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java
index f9dc5f11bcb0..90dd033a9079 100644
--- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java
+++ b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java
@@ -14,31 +14,56 @@
import static org.openhab.binding.myq.internal.MyQBindingConstants.*;
+import java.io.IOException;
+import java.io.UnsupportedEncodingException;
+import java.net.HttpCookie;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+import java.util.Arrays;
+import java.util.Base64;
import java.util.Collection;
import java.util.Collections;
+import java.util.Map;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.stream.Collectors;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.eclipse.jetty.client.HttpClient;
+import org.eclipse.jetty.client.HttpContentResponse;
import org.eclipse.jetty.client.api.ContentProvider;
+import org.eclipse.jetty.client.api.ContentResponse;
import org.eclipse.jetty.client.api.Request;
+import org.eclipse.jetty.client.api.Response;
import org.eclipse.jetty.client.api.Result;
import org.eclipse.jetty.client.util.BufferingResponseListener;
+import org.eclipse.jetty.client.util.FormContentProvider;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
+import org.eclipse.jetty.util.Fields;
+import org.jsoup.Jsoup;
+import org.jsoup.nodes.Document;
+import org.jsoup.nodes.Element;
import org.openhab.binding.myq.internal.MyQDiscoveryService;
import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
import org.openhab.binding.myq.internal.dto.AccountDTO;
import org.openhab.binding.myq.internal.dto.ActionDTO;
import org.openhab.binding.myq.internal.dto.DevicesDTO;
-import org.openhab.binding.myq.internal.dto.LoginRequestDTO;
-import org.openhab.binding.myq.internal.dto.LoginResponseDTO;
+import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
+import org.openhab.core.auth.client.oauth2.AccessTokenResponse;
+import org.openhab.core.auth.client.oauth2.OAuthClientService;
+import org.openhab.core.auth.client.oauth2.OAuthException;
+import org.openhab.core.auth.client.oauth2.OAuthFactory;
+import org.openhab.core.auth.client.oauth2.OAuthResponseException;
import org.openhab.core.thing.Bridge;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
@@ -63,7 +88,25 @@
* @author Dan Cunningham - Initial contribution
*/
@NonNullByDefault
-public class MyQAccountHandler extends BaseBridgeHandler {
+public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenRefreshListener {
+ /*
+ * MyQ oAuth relate fields
+ */
+ private static final String CLIENT_SECRET = "VUQ0RFhuS3lQV3EyNUJTdw==";
+ private static final String CLIENT_ID = "IOS_CGI_MYQ";
+ private static final String REDIRECT_URI = "com.myqops://ios";
+ private static final String SCOPE = "MyQ_Residential offline_access";
+ /*
+ * MyQ authentication API endpoints
+ */
+ private static final String LOGIN_BASE_URL = "https://partner-identity.myq-cloud.com";
+ private static final String LOGIN_AUTHORIZE_URL = LOGIN_BASE_URL + "/connect/authorize";
+ private static final String LOGIN_TOKEN_URL = LOGIN_BASE_URL + "/connect/token";
+ // this should never happen, but lets be safe and give up after so many redirects
+ private static final int LOGIN_MAX_REDIRECTS = 30;
+ /*
+ * MyQ device and account API endpoint
+ */
private static final String BASE_URL = "https://api.myqdevice.com/api";
private static final Integer RAPID_REFRESH_SECONDS = 5;
private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
@@ -71,20 +114,24 @@ public class MyQAccountHandler extends BaseBridgeHandler {
.create();
private final Gson gsonLowerCase = new GsonBuilder()
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
+ private final OAuthFactory oAuthFactory;
private @Nullable Future> normalPollFuture;
private @Nullable Future> rapidPollFuture;
- private @Nullable String securityToken;
private @Nullable AccountDTO account;
private @Nullable DevicesDTO devicesCache;
+ private @Nullable OAuthClientService oAuthService;
private Integer normalRefreshSeconds = 60;
private HttpClient httpClient;
private String username = "";
private String password = "";
private String userAgent = "";
+ // force login, even if we have a token
+ private boolean needsLogin = false;
- public MyQAccountHandler(Bridge bridge, HttpClient httpClient) {
+ public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) {
super(bridge);
this.httpClient = httpClient;
+ this.oAuthFactory = oAuthFactory;
}
@Override
@@ -98,8 +145,8 @@ public void initialize() {
username = config.username;
password = config.password;
// MyQ can get picky about blocking user agents apparently
- userAgent = MyQAccountHandler.randomString(40);
- securityToken = null;
+ userAgent = MyQAccountHandler.randomString(20);
+ needsLogin = true;
updateStatus(ThingStatus.UNKNOWN);
restartPolls(false);
}
@@ -107,6 +154,9 @@ public void initialize() {
@Override
public void dispose() {
stopPolls();
+ if (oAuthService != null) {
+ oAuthService.close();
+ }
}
@Override
@@ -125,6 +175,11 @@ public void childHandlerInitialized(ThingHandler childHandler, Thing childThing)
}
}
+ @Override
+ public void onAccessTokenResponse(AccessTokenResponse tokenResponse) {
+ logger.debug("Auth Token Refreshed, expires in {}", tokenResponse.getExpiresIn());
+ }
+
/**
* Sends an action to the MyQ API
*
@@ -132,20 +187,26 @@ public void childHandlerInitialized(ThingHandler childHandler, Thing childThing)
* @param action
*/
public void sendAction(String serialNumber, String action) {
+ if (getThing().getStatus() != ThingStatus.ONLINE) {
+ logger.debug("Account offline, ignoring action {}", action);
+ return;
+ }
+
AccountDTO localAccount = account;
if (localAccount != null) {
try {
- HttpResult result = sendRequest(
+ ContentResponse response = sendRequest(
String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
serialNumber),
- HttpMethod.PUT, securityToken,
- new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))), "application/json");
- if (HttpStatus.isSuccess(result.responseCode)) {
+ HttpMethod.PUT, new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))),
+ "application/json");
+ if (HttpStatus.isSuccess(response.getStatus())) {
restartPolls(true);
} else {
- logger.debug("Failed to send action {} : {}", action, result.content);
+ logger.debug("Failed to send action {} : {}", action, response.getContentAsString());
}
- } catch (InterruptedException e) {
+ } catch (InterruptedException | MyQCommunicationException | MyQAuthenticationException e) {
+ logger.debug("Could not send action", e);
}
}
}
@@ -204,131 +265,317 @@ private void rapidPoll() {
private synchronized void fetchData() {
try {
- if (securityToken == null) {
- login();
- if (securityToken != null) {
- getAccount();
- }
- }
- if (securityToken != null) {
- getDevices();
+ if (account == null) {
+ getAccount();
}
+ getDevices();
+ } catch (MyQCommunicationException e) {
+ logger.debug("MyQ communication error", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ } catch (MyQAuthenticationException e) {
+ logger.debug("MyQ authentication error", e);
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
+ stopPolls();
} catch (InterruptedException e) {
+ // we were shut down, ignore
}
}
- private void login() throws InterruptedException {
- HttpResult result = sendRequest(BASE_URL + "/v5/Login", HttpMethod.POST, null,
- new StringContentProvider(gsonUpperCase.toJson(new LoginRequestDTO(username, password))),
- "application/json");
- LoginResponseDTO loginResponse = parseResultAndUpdateStatus(result, gsonUpperCase, LoginResponseDTO.class);
- if (loginResponse != null) {
- securityToken = loginResponse.securityToken;
- } else {
- securityToken = null;
- if (thing.getStatusInfo().getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
- // bad credentials, stop trying to login
- stopPolls();
+ /**
+ * This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
+ *
+ * @return AccessTokenResponse token
+ * @throws InterruptedException
+ * @throws MyQCommunicationException
+ * @throws MyQAuthenticationException
+ */
+ private AccessTokenResponse login()
+ throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
+ // make sure we have a fresh session
+ httpClient.getCookieStore().removeAll();
+
+ try {
+ String codeVerifier = generateCodeVerifier();
+
+ ContentResponse loginPageResponse = getLoginPage(codeVerifier);
+
+ // load the login page to get cookies and form parameters
+ Document loginPage = Jsoup.parse(loginPageResponse.getContentAsString());
+ Element form = loginPage.select("form").first();
+ Element requestToken = loginPage.select("input[name=__RequestVerificationToken]").first();
+ Element returnURL = loginPage.select("input[name=ReturnUrl]").first();
+
+ if (form == null || requestToken == null) {
+ throw new MyQCommunicationException("Could not load login page");
+ }
+
+ // url that the form will submit to
+ String action = LOGIN_BASE_URL + form.attr("action");
+
+ // post our user name and password along with elements from the scraped form
+ String location = postLogin(action, requestToken.attr("value"), returnURL.attr("value"));
+ if (location == null) {
+ throw new MyQAuthenticationException("Could not login with credentials");
}
+
+ // finally complete the oAuth flow and retrieve a JSON oAuth token response
+ ContentResponse tokenResponse = getLoginToken(location, codeVerifier);
+ String loginToken = tokenResponse.getContentAsString();
+
+ AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class);
+ if (accessTokenResponse == null) {
+ throw new MyQAuthenticationException("Could not parse token response");
+ }
+ getOAuthService().importAccessTokenResponse(accessTokenResponse);
+ return accessTokenResponse;
+ } catch (IOException | ExecutionException | TimeoutException | OAuthException e) {
+ throw new MyQCommunicationException(e.getMessage());
}
}
- private void getAccount() throws InterruptedException {
- HttpResult result = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, securityToken, null, null);
- account = parseResultAndUpdateStatus(result, gsonUpperCase, AccountDTO.class);
+ private void getAccount() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
+ ContentResponse response = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, null, null);
+ account = parseResultAndUpdateStatus(response, gsonUpperCase, AccountDTO.class);
}
- private void getDevices() throws InterruptedException {
+ private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
AccountDTO localAccount = account;
if (localAccount == null) {
return;
}
- HttpResult result = sendRequest(String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id),
- HttpMethod.GET, securityToken, null, null);
- DevicesDTO devices = parseResultAndUpdateStatus(result, gsonLowerCase, DevicesDTO.class);
- if (devices != null) {
- devicesCache = devices;
- devices.items.forEach(device -> {
- ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
- if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
- for (Thing thing : getThing().getThings()) {
- ThingHandler handler = thing.getHandler();
- if (handler != null && ((MyQDeviceHandler) handler).getSerialNumber()
- .equalsIgnoreCase(device.serialNumber)) {
- ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
- }
+ ContentResponse response = sendRequest(
+ String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id), HttpMethod.GET, null,
+ null);
+ DevicesDTO devices = parseResultAndUpdateStatus(response, gsonLowerCase, DevicesDTO.class);
+ devicesCache = devices;
+ devices.items.forEach(device -> {
+ ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
+ if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
+ for (Thing thing : getThing().getThings()) {
+ ThingHandler handler = thing.getHandler();
+ if (handler != null
+ && ((MyQDeviceHandler) handler).getSerialNumber().equalsIgnoreCase(device.serialNumber)) {
+ ((MyQDeviceHandler) handler).handleDeviceUpdate(device);
}
}
- });
- }
+ }
+ });
}
- private synchronized HttpResult sendRequest(String url, HttpMethod method, @Nullable String token,
- @Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException {
- try {
- Request request = httpClient.newRequest(url).method(method)
- .header("MyQApplicationId", "JVM/G9Nwih5BwKgNCjLxiFUQxQijAebyyg8QUHr7JOrP+tuPb8iHfRHKwTmDzHOu")
- .header("ApiVersion", "5.1").header("BrandId", "2").header("Culture", "en").agent(userAgent)
- .timeout(10, TimeUnit.SECONDS);
- if (token != null) {
- request = request.header("SecurityToken", token);
+ private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content,
+ @Nullable String contentType)
+ throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
+ AccessTokenResponse tokenResponse = null;
+ // if we don't need to force a login, attempt to use the token we have
+ if (!needsLogin) {
+ try {
+ tokenResponse = getOAuthService().getAccessTokenResponse();
+ } catch (OAuthException | IOException | OAuthResponseException e) {
+ // ignore error, will try to login below
+ logger.debug("Error accessing token, will attempt to login again", e);
}
- if (content != null & contentType != null) {
- request = request.content(content, contentType);
+ }
+
+ // if no token, or we need to login, do so now
+ if (tokenResponse == null) {
+ tokenResponse = login();
+ needsLogin = false;
+ }
+
+ Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS)
+ .header("Authorization", authTokenHeader(tokenResponse));
+ if (content != null & contentType != null) {
+ request = request.content(content, contentType);
+ }
+
+ // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
+ // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
+ // prevents us from knowing the response code
+ logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
+ final CompletableFuture futureResult = new CompletableFuture<>();
+ request.send(new BufferingResponseListener() {
+ @NonNullByDefault({})
+ @Override
+ public void onComplete(Result result) {
+ Response response = result.getResponse();
+ futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding()));
}
- // use asyc jetty as the API service will response with a 401 error when credentials are wrong,
- // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
- // prevents us from knowing the response code
- logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
- final CompletableFuture futureResult = new CompletableFuture<>();
- request.send(new BufferingResponseListener() {
- @NonNullByDefault({})
- @Override
- public void onComplete(Result result) {
- futureResult.complete(new HttpResult(result.getResponse().getStatus(), getContentAsString()));
- }
- });
- HttpResult result = futureResult.get();
- logger.trace("Account Response - status: {} content: {}", result.responseCode, result.content);
+ });
+
+ try {
+ ContentResponse result = futureResult.get();
+ logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString());
return result;
} catch (ExecutionException e) {
- return new HttpResult(0, e.getMessage());
+ throw new MyQCommunicationException(e.getMessage());
}
}
- @Nullable
- private T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class classOfT) {
- if (HttpStatus.isSuccess(result.responseCode)) {
+ private T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class classOfT)
+ throws MyQCommunicationException {
+ if (HttpStatus.isSuccess(response.getStatus())) {
try {
- T responseObject = parser.fromJson(result.content, classOfT);
+ T responseObject = parser.fromJson(response.getContentAsString(), classOfT);
if (responseObject != null) {
if (getThing().getStatus() != ThingStatus.ONLINE) {
updateStatus(ThingStatus.ONLINE);
}
return responseObject;
+ } else {
+ throw new MyQCommunicationException("Bad response from server");
}
} catch (JsonSyntaxException e) {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Invalid JSON Response " + result.content);
+ throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString());
}
- } else if (result.responseCode == HttpStatus.UNAUTHORIZED_401) {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
- "Unauthorized - Check Credentials");
+ } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
+ // our tokens no longer work, will need to login again
+ needsLogin = true;
+ throw new MyQCommunicationException("Token was rejected for request");
} else {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
- "Invalid Response Code " + result.responseCode + " : " + result.content);
+ throw new MyQCommunicationException(
+ "Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString());
}
- return null;
}
- private class HttpResult {
- public final int responseCode;
- public @Nullable String content;
+ /**
+ * Returns the MyQ login page which contains form elements and cookies needed to login
+ *
+ * @param codeVerifier
+ * @return
+ * @throws InterruptedException
+ * @throws ExecutionException
+ * @throws TimeoutException
+ */
+ private ContentResponse getLoginPage(String codeVerifier)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ try {
+ Request request = httpClient.newRequest(LOGIN_AUTHORIZE_URL) //
+ .param("client_id", CLIENT_ID) //
+ .param("code_challenge", generateCodeChallange(codeVerifier)) //
+ .param("code_challenge_method", "S256") //
+ .param("redirect_uri", REDIRECT_URI) //
+ .param("response_type", "code") //
+ .param("scope", SCOPE) //
+ .agent(userAgent).followRedirects(true);
+ logger.debug("Sending {} to {}", request.getMethod(), request.getURI());
+ ContentResponse response = request.send();
+ logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
+ return response;
+ } catch (UnsupportedEncodingException | NoSuchAlgorithmException e) {
+ throw new ExecutionException(e.getCause());
+ }
+ }
- public HttpResult(int responseCode, @Nullable String content) {
- this.responseCode = responseCode;
- this.content = content;
+ /**
+ * Sends configured credentials and elements from the login page in order to obtain a redirect location header value
+ *
+ * @param url
+ * @param requestToken
+ * @param returnURL
+ * @return The location header value
+ * @throws InterruptedException
+ * @throws ExecutionException
+ * @throws TimeoutException
+ */
+ @Nullable
+ private String postLogin(String url, String requestToken, String returnURL)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ /*
+ * on a successful post to this page we will get several redirects, and a final 301 to:
+ * com.myqops://ios?code=0123456789&scope=MyQ_Residential%20offline_access&iss=https%3A%2F%2Fpartner-identity.
+ * myq-cloud.com
+ *
+ * We can then take the parameters out of this location and continue the process
+ */
+ Fields fields = new Fields();
+ fields.add("Email", username);
+ fields.add("Password", password);
+ fields.add("__RequestVerificationToken", requestToken);
+ fields.add("ReturnUrl", returnURL);
+
+ Request request = httpClient.newRequest(url).method(HttpMethod.POST) //
+ .content(new FormContentProvider(fields)) //
+ .agent(userAgent) //
+ .followRedirects(false);
+ setCookies(request);
+
+ logger.debug("Posting Login to {}", url);
+ ContentResponse response = request.send();
+
+ String location = null;
+
+ // follow redirects until we match our REDIRECT_URI or hit a redirect safety limit
+ for (int i = 0; i < LOGIN_MAX_REDIRECTS && HttpStatus.isRedirection(response.getStatus()); i++) {
+
+ String loc = response.getHeaders().get("location");
+ if (logger.isTraceEnabled()) {
+ logger.trace("Redirect Login: Code {} Location Header: {} Response {}", response.getStatus(), loc,
+ response.getContentAsString());
+ }
+ if (loc == null) {
+ logger.debug("No location value");
+ break;
+ }
+ if (loc.indexOf(REDIRECT_URI) == 0) {
+ location = loc;
+ break;
+ }
+ request = httpClient.newRequest(LOGIN_BASE_URL + loc).agent(userAgent).followRedirects(false);
+ setCookies(request);
+ response = request.send();
+ }
+ return location;
+ }
+
+ /**
+ * Final step of the login process to get a oAuth access response token
+ *
+ * @param redirectLocation
+ * @param codeVerifier
+ * @return
+ * @throws InterruptedException
+ * @throws ExecutionException
+ * @throws TimeoutException
+ */
+ private ContentResponse getLoginToken(String redirectLocation, String codeVerifier)
+ throws InterruptedException, ExecutionException, TimeoutException {
+ try {
+ Map params = parseLocationQuery(redirectLocation);
+
+ Fields fields = new Fields();
+ fields.add("client_id", CLIENT_ID);
+ fields.add("client_secret", Base64.getEncoder().encodeToString(CLIENT_SECRET.getBytes()));
+ fields.add("code", params.get("code"));
+ fields.add("code_verifier", codeVerifier);
+ fields.add("grant_type", "authorization_code");
+ fields.add("redirect_uri", REDIRECT_URI);
+ fields.add("scope", params.get("scope"));
+
+ Request request = httpClient.newRequest(LOGIN_TOKEN_URL) //
+ .content(new FormContentProvider(fields)) //
+ .method(HttpMethod.POST) //
+ .agent(userAgent).followRedirects(true);
+ setCookies(request);
+
+ ContentResponse response = request.send();
+ if (logger.isTraceEnabled()) {
+ logger.trace("Login Code {} Response {}", response.getStatus(), response.getContentAsString());
+ }
+ return response;
+ } catch (URISyntaxException e) {
+ throw new ExecutionException(e.getCause());
+ }
+ }
+
+ private OAuthClientService getOAuthService() {
+ OAuthClientService oAuthService = this.oAuthService;
+ if (oAuthService == null || oAuthService.isClosed()) {
+ oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL,
+ LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false);
+ oAuthService.addAccessTokenRefreshListener(this);
+ this.oAuthService = oAuthService;
}
+ return oAuthService;
}
private static String randomString(int length) {
@@ -341,4 +588,58 @@ private static String randomString(int length) {
}
return sb.toString();
}
+
+ private String generateCodeVerifier() throws UnsupportedEncodingException {
+ SecureRandom secureRandom = new SecureRandom();
+ byte[] codeVerifier = new byte[32];
+ secureRandom.nextBytes(codeVerifier);
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(codeVerifier);
+ }
+
+ private String generateCodeChallange(String codeVerifier)
+ throws UnsupportedEncodingException, NoSuchAlgorithmException {
+ byte[] bytes = codeVerifier.getBytes("US-ASCII");
+ MessageDigest messageDigest = MessageDigest.getInstance("SHA-256");
+ messageDigest.update(bytes, 0, bytes.length);
+ byte[] digest = messageDigest.digest();
+ return Base64.getUrlEncoder().withoutPadding().encodeToString(digest);
+ }
+
+ private Map parseLocationQuery(String location) throws URISyntaxException {
+ URI uri = new URI(location);
+ return Arrays.stream(uri.getQuery().split("&")).map(str -> str.split("="))
+ .collect(Collectors.toMap(str -> str[0], str -> str[1]));
+ }
+
+ private void setCookies(Request request) {
+ for (HttpCookie c : httpClient.getCookieStore().getCookies()) {
+ request.cookie(c);
+ }
+ }
+
+ private String authTokenHeader(AccessTokenResponse tokenResponse) {
+ return tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken();
+ }
+
+ /**
+ * Exception for authenticated related errors
+ */
+ class MyQAuthenticationException extends Exception {
+ private static final long serialVersionUID = 1L;
+
+ public MyQAuthenticationException(String message) {
+ super(message);
+ }
+ }
+
+ /**
+ * Generic exception for non authentication related errors when communicating with the MyQ service.
+ */
+ class MyQCommunicationException extends IOException {
+ private static final long serialVersionUID = 1L;
+
+ public MyQCommunicationException(@Nullable String message) {
+ super(message);
+ }
+ }
}