|
|
|
|
@@ -14,31 +14,56 @@ package org.openhab.binding.myq.internal.handler;
|
|
|
|
|
|
|
|
|
|
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 @@ import com.google.gson.JsonSyntaxException;
|
|
|
|
|
* @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 class MyQAccountHandler extends BaseBridgeHandler {
|
|
|
|
|
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 class MyQAccountHandler extends BaseBridgeHandler {
|
|
|
|
|
@Override
|
|
|
|
|
public void dispose() {
|
|
|
|
|
stopPolls();
|
|
|
|
|
if (oAuthService != null) {
|
|
|
|
|
oAuthService.close();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
@@ -125,6 +175,11 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@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 class MyQAccountHandler extends BaseBridgeHandler {
|
|
|
|
|
* @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,133 +265,319 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|
|
|
|
|
|
|
|
|
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 {
|
|
|
|
|
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 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<ContentResponse> 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()));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
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<HttpResult> 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);
|
|
|
|
|
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> T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class<T> classOfT) {
|
|
|
|
|
if (HttpStatus.isSuccess(result.responseCode)) {
|
|
|
|
|
private <T> T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class<T> 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;
|
|
|
|
|
|
|
|
|
|
public HttpResult(int responseCode, @Nullable String content) {
|
|
|
|
|
this.responseCode = responseCode;
|
|
|
|
|
this.content = 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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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<String, String> 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) {
|
|
|
|
|
int low = 97; // a-z
|
|
|
|
|
int high = 122; // A-Z
|
|
|
|
|
@@ -341,4 +588,58 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|
|
|
|
}
|
|
|
|
|
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<String, String> 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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|