[myq] Switch to using oAuth for logins (#11183)
* change MyQ binding to use now required oAuth for authentication Signed-off-by: Dan Cunningham <dan@digitaldan.com> * Clean up error handling Signed-off-by: Dan Cunningham <dan@digitaldan.com> * Cleanup checkstyle errors Signed-off-by: Dan Cunningham <dan@digitaldan.com> * missing newline Signed-off-by: Dan Cunningham <dan@digitaldan.com> * Remove unused classes Signed-off-by: Dan Cunningham <dan@digitaldan.com> * Add token listener Signed-off-by: Dan Cunningham <dan@digitaldan.com> * add a redirect limit...just in case Signed-off-by: Dan Cunningham <dan@digitaldan.com> * Don't resue the oAuth service if we have been disosed or its closed. Reduce logging verbosity. Signed-off-by: Dan Cunningham <dan@digitaldan.com> * Force login if we get a 401 response Signed-off-by: Dan Cunningham <dan@digitaldan.com>
This commit is contained in:
parent
f055795c28
commit
7efc3e9e81
@ -11,3 +11,10 @@ https://www.eclipse.org/legal/epl-2.0/.
|
|||||||
== Source Code
|
== Source Code
|
||||||
|
|
||||||
https://github.com/openhab/openhab-addons
|
https://github.com/openhab/openhab-addons
|
||||||
|
|
||||||
|
== Third-party Content
|
||||||
|
|
||||||
|
jsoup
|
||||||
|
* License: MIT License
|
||||||
|
* Project: https://jsoup.org/
|
||||||
|
* Source: https://github.com/jhy/jsoup
|
||||||
|
|||||||
@ -14,4 +14,12 @@
|
|||||||
|
|
||||||
<name>openHAB Add-ons :: Bundles :: MyQ Binding</name>
|
<name>openHAB Add-ons :: Bundles :: MyQ Binding</name>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.jsoup</groupId>
|
||||||
|
<artifactId>jsoup</artifactId>
|
||||||
|
<version>1.8.3</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
</project>
|
</project>
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
<feature name="openhab-binding-myq" description="MyQ Binding" version="${project.version}">
|
<feature name="openhab-binding-myq" description="MyQ Binding" version="${project.version}">
|
||||||
<feature>openhab-runtime-base</feature>
|
<feature>openhab-runtime-base</feature>
|
||||||
|
<bundle dependency="true">mvn:org.jsoup/jsoup/1.8.3</bundle>
|
||||||
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.myq/${project.version}</bundle>
|
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.myq/${project.version}</bundle>
|
||||||
</feature>
|
</feature>
|
||||||
</features>
|
</features>
|
||||||
|
|||||||
@ -20,6 +20,7 @@ import org.eclipse.jetty.client.HttpClient;
|
|||||||
import org.openhab.binding.myq.internal.handler.MyQAccountHandler;
|
import org.openhab.binding.myq.internal.handler.MyQAccountHandler;
|
||||||
import org.openhab.binding.myq.internal.handler.MyQGarageDoorHandler;
|
import org.openhab.binding.myq.internal.handler.MyQGarageDoorHandler;
|
||||||
import org.openhab.binding.myq.internal.handler.MyQLampHandler;
|
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.io.net.http.HttpClientFactory;
|
||||||
import org.openhab.core.thing.Bridge;
|
import org.openhab.core.thing.Bridge;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
@ -41,10 +42,13 @@ import org.osgi.service.component.annotations.Reference;
|
|||||||
@Component(configurationPid = "binding.myq", service = ThingHandlerFactory.class)
|
@Component(configurationPid = "binding.myq", service = ThingHandlerFactory.class)
|
||||||
public class MyQHandlerFactory extends BaseThingHandlerFactory {
|
public class MyQHandlerFactory extends BaseThingHandlerFactory {
|
||||||
private final HttpClient httpClient;
|
private final HttpClient httpClient;
|
||||||
|
private OAuthFactory oAuthFactory;
|
||||||
|
|
||||||
@Activate
|
@Activate
|
||||||
public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory) {
|
public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory,
|
||||||
|
final @Reference OAuthFactory oAuthFactory) {
|
||||||
this.httpClient = httpClientFactory.getCommonHttpClient();
|
this.httpClient = httpClientFactory.getCommonHttpClient();
|
||||||
|
this.oAuthFactory = oAuthFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -57,7 +61,7 @@ public class MyQHandlerFactory extends BaseThingHandlerFactory {
|
|||||||
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
ThingTypeUID thingTypeUID = thing.getThingTypeUID();
|
||||||
|
|
||||||
if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) {
|
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)) {
|
if (THING_TYPE_GARAGEDOOR.equals(thingTypeUID)) {
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -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;
|
|
||||||
}
|
|
||||||
@ -14,31 +14,56 @@ package org.openhab.binding.myq.internal.handler;
|
|||||||
|
|
||||||
import static org.openhab.binding.myq.internal.MyQBindingConstants.*;
|
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.Collection;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
import java.util.Map;
|
||||||
import java.util.Random;
|
import java.util.Random;
|
||||||
import java.util.concurrent.CompletableFuture;
|
import java.util.concurrent.CompletableFuture;
|
||||||
import java.util.concurrent.ExecutionException;
|
import java.util.concurrent.ExecutionException;
|
||||||
import java.util.concurrent.Future;
|
import java.util.concurrent.Future;
|
||||||
import java.util.concurrent.TimeUnit;
|
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.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
import org.eclipse.jetty.client.HttpClient;
|
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.ContentProvider;
|
||||||
|
import org.eclipse.jetty.client.api.ContentResponse;
|
||||||
import org.eclipse.jetty.client.api.Request;
|
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.api.Result;
|
||||||
import org.eclipse.jetty.client.util.BufferingResponseListener;
|
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.client.util.StringContentProvider;
|
||||||
import org.eclipse.jetty.http.HttpMethod;
|
import org.eclipse.jetty.http.HttpMethod;
|
||||||
import org.eclipse.jetty.http.HttpStatus;
|
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.MyQDiscoveryService;
|
||||||
import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
|
import org.openhab.binding.myq.internal.config.MyQAccountConfiguration;
|
||||||
import org.openhab.binding.myq.internal.dto.AccountDTO;
|
import org.openhab.binding.myq.internal.dto.AccountDTO;
|
||||||
import org.openhab.binding.myq.internal.dto.ActionDTO;
|
import org.openhab.binding.myq.internal.dto.ActionDTO;
|
||||||
import org.openhab.binding.myq.internal.dto.DevicesDTO;
|
import org.openhab.binding.myq.internal.dto.DevicesDTO;
|
||||||
import org.openhab.binding.myq.internal.dto.LoginRequestDTO;
|
import org.openhab.core.auth.client.oauth2.AccessTokenRefreshListener;
|
||||||
import org.openhab.binding.myq.internal.dto.LoginResponseDTO;
|
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.Bridge;
|
||||||
import org.openhab.core.thing.ChannelUID;
|
import org.openhab.core.thing.ChannelUID;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
@ -63,7 +88,25 @@ import com.google.gson.JsonSyntaxException;
|
|||||||
* @author Dan Cunningham - Initial contribution
|
* @author Dan Cunningham - Initial contribution
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@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 String BASE_URL = "https://api.myqdevice.com/api";
|
||||||
private static final Integer RAPID_REFRESH_SECONDS = 5;
|
private static final Integer RAPID_REFRESH_SECONDS = 5;
|
||||||
private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
|
private final Logger logger = LoggerFactory.getLogger(MyQAccountHandler.class);
|
||||||
@ -71,20 +114,24 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|||||||
.create();
|
.create();
|
||||||
private final Gson gsonLowerCase = new GsonBuilder()
|
private final Gson gsonLowerCase = new GsonBuilder()
|
||||||
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
|
.setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create();
|
||||||
|
private final OAuthFactory oAuthFactory;
|
||||||
private @Nullable Future<?> normalPollFuture;
|
private @Nullable Future<?> normalPollFuture;
|
||||||
private @Nullable Future<?> rapidPollFuture;
|
private @Nullable Future<?> rapidPollFuture;
|
||||||
private @Nullable String securityToken;
|
|
||||||
private @Nullable AccountDTO account;
|
private @Nullable AccountDTO account;
|
||||||
private @Nullable DevicesDTO devicesCache;
|
private @Nullable DevicesDTO devicesCache;
|
||||||
|
private @Nullable OAuthClientService oAuthService;
|
||||||
private Integer normalRefreshSeconds = 60;
|
private Integer normalRefreshSeconds = 60;
|
||||||
private HttpClient httpClient;
|
private HttpClient httpClient;
|
||||||
private String username = "";
|
private String username = "";
|
||||||
private String password = "";
|
private String password = "";
|
||||||
private String userAgent = "";
|
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);
|
super(bridge);
|
||||||
this.httpClient = httpClient;
|
this.httpClient = httpClient;
|
||||||
|
this.oAuthFactory = oAuthFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@ -98,8 +145,8 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|||||||
username = config.username;
|
username = config.username;
|
||||||
password = config.password;
|
password = config.password;
|
||||||
// MyQ can get picky about blocking user agents apparently
|
// MyQ can get picky about blocking user agents apparently
|
||||||
userAgent = MyQAccountHandler.randomString(40);
|
userAgent = MyQAccountHandler.randomString(20);
|
||||||
securityToken = null;
|
needsLogin = true;
|
||||||
updateStatus(ThingStatus.UNKNOWN);
|
updateStatus(ThingStatus.UNKNOWN);
|
||||||
restartPolls(false);
|
restartPolls(false);
|
||||||
}
|
}
|
||||||
@ -107,6 +154,9 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|||||||
@Override
|
@Override
|
||||||
public void dispose() {
|
public void dispose() {
|
||||||
stopPolls();
|
stopPolls();
|
||||||
|
if (oAuthService != null) {
|
||||||
|
oAuthService.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@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
|
* Sends an action to the MyQ API
|
||||||
*
|
*
|
||||||
@ -132,20 +187,26 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|||||||
* @param action
|
* @param action
|
||||||
*/
|
*/
|
||||||
public void sendAction(String serialNumber, String action) {
|
public void sendAction(String serialNumber, String action) {
|
||||||
|
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
||||||
|
logger.debug("Account offline, ignoring action {}", action);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
AccountDTO localAccount = account;
|
AccountDTO localAccount = account;
|
||||||
if (localAccount != null) {
|
if (localAccount != null) {
|
||||||
try {
|
try {
|
||||||
HttpResult result = sendRequest(
|
ContentResponse response = sendRequest(
|
||||||
String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
|
String.format("%s/v5.1/Accounts/%s/Devices/%s/actions", BASE_URL, localAccount.account.id,
|
||||||
serialNumber),
|
serialNumber),
|
||||||
HttpMethod.PUT, securityToken,
|
HttpMethod.PUT, new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))),
|
||||||
new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(action))), "application/json");
|
"application/json");
|
||||||
if (HttpStatus.isSuccess(result.responseCode)) {
|
if (HttpStatus.isSuccess(response.getStatus())) {
|
||||||
restartPolls(true);
|
restartPolls(true);
|
||||||
} else {
|
} 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() {
|
private synchronized void fetchData() {
|
||||||
try {
|
try {
|
||||||
if (securityToken == null) {
|
if (account == null) {
|
||||||
login();
|
getAccount();
|
||||||
if (securityToken != null) {
|
|
||||||
getAccount();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (securityToken != null) {
|
|
||||||
getDevices();
|
|
||||||
}
|
}
|
||||||
|
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) {
|
} catch (InterruptedException e) {
|
||||||
|
// we were shut down, ignore
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void login() throws InterruptedException {
|
/**
|
||||||
HttpResult result = sendRequest(BASE_URL + "/v5/Login", HttpMethod.POST, null,
|
* This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
|
||||||
new StringContentProvider(gsonUpperCase.toJson(new LoginRequestDTO(username, password))),
|
*
|
||||||
"application/json");
|
* @return AccessTokenResponse token
|
||||||
LoginResponseDTO loginResponse = parseResultAndUpdateStatus(result, gsonUpperCase, LoginResponseDTO.class);
|
* @throws InterruptedException
|
||||||
if (loginResponse != null) {
|
* @throws MyQCommunicationException
|
||||||
securityToken = loginResponse.securityToken;
|
* @throws MyQAuthenticationException
|
||||||
} else {
|
*/
|
||||||
securityToken = null;
|
private AccessTokenResponse login()
|
||||||
if (thing.getStatusInfo().getStatusDetail() == ThingStatusDetail.CONFIGURATION_ERROR) {
|
throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
|
||||||
// bad credentials, stop trying to login
|
// make sure we have a fresh session
|
||||||
stopPolls();
|
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 {
|
private void getAccount() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
|
||||||
HttpResult result = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, securityToken, null, null);
|
ContentResponse response = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, null, null);
|
||||||
account = parseResultAndUpdateStatus(result, gsonUpperCase, AccountDTO.class);
|
account = parseResultAndUpdateStatus(response, gsonUpperCase, AccountDTO.class);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void getDevices() throws InterruptedException {
|
private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException {
|
||||||
AccountDTO localAccount = account;
|
AccountDTO localAccount = account;
|
||||||
if (localAccount == null) {
|
if (localAccount == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
HttpResult result = sendRequest(String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id),
|
ContentResponse response = sendRequest(
|
||||||
HttpMethod.GET, securityToken, null, null);
|
String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id), HttpMethod.GET, null,
|
||||||
DevicesDTO devices = parseResultAndUpdateStatus(result, gsonLowerCase, DevicesDTO.class);
|
null);
|
||||||
if (devices != null) {
|
DevicesDTO devices = parseResultAndUpdateStatus(response, gsonLowerCase, DevicesDTO.class);
|
||||||
devicesCache = devices;
|
devicesCache = devices;
|
||||||
devices.items.forEach(device -> {
|
devices.items.forEach(device -> {
|
||||||
ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
|
ThingTypeUID thingTypeUID = new ThingTypeUID(BINDING_ID, device.deviceFamily);
|
||||||
if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
|
if (SUPPORTED_DISCOVERY_THING_TYPES_UIDS.contains(thingTypeUID)) {
|
||||||
for (Thing thing : getThing().getThings()) {
|
for (Thing thing : getThing().getThings()) {
|
||||||
ThingHandler handler = thing.getHandler();
|
ThingHandler handler = thing.getHandler();
|
||||||
if (handler != null && ((MyQDeviceHandler) handler).getSerialNumber()
|
if (handler != null
|
||||||
.equalsIgnoreCase(device.serialNumber)) {
|
&& ((MyQDeviceHandler) handler).getSerialNumber().equalsIgnoreCase(device.serialNumber)) {
|
||||||
((MyQDeviceHandler) handler).handleDeviceUpdate(device);
|
((MyQDeviceHandler) handler).handleDeviceUpdate(device);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
}
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private synchronized HttpResult sendRequest(String url, HttpMethod method, @Nullable String token,
|
private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content,
|
||||||
@Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException {
|
@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 {
|
try {
|
||||||
Request request = httpClient.newRequest(url).method(method)
|
ContentResponse result = futureResult.get();
|
||||||
.header("MyQApplicationId", "JVM/G9Nwih5BwKgNCjLxiFUQxQijAebyyg8QUHr7JOrP+tuPb8iHfRHKwTmDzHOu")
|
logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString());
|
||||||
.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);
|
|
||||||
return result;
|
return result;
|
||||||
} catch (ExecutionException e) {
|
} catch (ExecutionException e) {
|
||||||
return new HttpResult(0, e.getMessage());
|
throw new MyQCommunicationException(e.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
private <T> T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class<T> classOfT)
|
||||||
private <T> T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class<T> classOfT) {
|
throws MyQCommunicationException {
|
||||||
if (HttpStatus.isSuccess(result.responseCode)) {
|
if (HttpStatus.isSuccess(response.getStatus())) {
|
||||||
try {
|
try {
|
||||||
T responseObject = parser.fromJson(result.content, classOfT);
|
T responseObject = parser.fromJson(response.getContentAsString(), classOfT);
|
||||||
if (responseObject != null) {
|
if (responseObject != null) {
|
||||||
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
if (getThing().getStatus() != ThingStatus.ONLINE) {
|
||||||
updateStatus(ThingStatus.ONLINE);
|
updateStatus(ThingStatus.ONLINE);
|
||||||
}
|
}
|
||||||
return responseObject;
|
return responseObject;
|
||||||
|
} else {
|
||||||
|
throw new MyQCommunicationException("Bad response from server");
|
||||||
}
|
}
|
||||||
} catch (JsonSyntaxException e) {
|
} catch (JsonSyntaxException e) {
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString());
|
||||||
"Invalid JSON Response " + result.content);
|
|
||||||
}
|
}
|
||||||
} else if (result.responseCode == HttpStatus.UNAUTHORIZED_401) {
|
} else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) {
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
|
// our tokens no longer work, will need to login again
|
||||||
"Unauthorized - Check Credentials");
|
needsLogin = true;
|
||||||
|
throw new MyQCommunicationException("Token was rejected for request");
|
||||||
} else {
|
} else {
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
throw new MyQCommunicationException(
|
||||||
"Invalid Response Code " + result.responseCode + " : " + result.content);
|
"Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString());
|
||||||
}
|
}
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class HttpResult {
|
/**
|
||||||
public final int responseCode;
|
* Returns the MyQ login page which contains form elements and cookies needed to login
|
||||||
public @Nullable String content;
|
*
|
||||||
|
* @param codeVerifier
|
||||||
public HttpResult(int responseCode, @Nullable String content) {
|
* @return
|
||||||
this.responseCode = responseCode;
|
* @throws InterruptedException
|
||||||
this.content = content;
|
* @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) {
|
private static String randomString(int length) {
|
||||||
int low = 97; // a-z
|
int low = 97; // a-z
|
||||||
int high = 122; // A-Z
|
int high = 122; // A-Z
|
||||||
@ -341,4 +588,58 @@ public class MyQAccountHandler extends BaseBridgeHandler {
|
|||||||
}
|
}
|
||||||
return sb.toString();
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user