[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:
Dan Cunningham 2021-09-11 04:41:28 -07:00 committed by GitHub
parent f055795c28
commit 7efc3e9e81
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 422 additions and 153 deletions

View File

@ -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

View File

@ -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>

View File

@ -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>

View File

@ -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)) {

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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();
if (securityToken != null) {
getAccount(); getAccount();
} }
}
if (securityToken != null) {
getDevices(); getDevices();
} } catch (MyQCommunicationException e) {
} catch (InterruptedException e) { logger.debug("MyQ communication error", e);
} updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
} } catch (MyQAuthenticationException e) {
logger.debug("MyQ authentication error", e);
private void login() throws InterruptedException { updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
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(); stopPolls();
} } catch (InterruptedException e) {
// we were shut down, ignore
} }
} }
private void getAccount() throws InterruptedException { /**
HttpResult result = sendRequest(BASE_URL + "/v5/My?expand=account", HttpMethod.GET, securityToken, null, null); * This attempts to navigate the MyQ oAuth login flow in order to obtain a @AccessTokenResponse
account = parseResultAndUpdateStatus(result, gsonUpperCase, AccountDTO.class); *
* @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");
} }
private void getDevices() throws InterruptedException { // 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, 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, 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 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);
}
} }
private synchronized HttpResult sendRequest(String url, HttpMethod method, @Nullable String token, // if no token, or we need to login, do so now
@Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException { if (tokenResponse == null) {
try { tokenResponse = login();
Request request = httpClient.newRequest(url).method(method) needsLogin = false;
.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);
} }
Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS)
.header("Authorization", authTokenHeader(tokenResponse));
if (content != null & contentType != null) { if (content != null & contentType != null) {
request = request.content(content, contentType); request = request.content(content, contentType);
} }
// use asyc jetty as the API service will response with a 401 error when credentials are wrong, // 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 // but not a WWW-Authenticate header which causes Jetty to throw a generic execution exception which
// prevents us from knowing the response code // prevents us from knowing the response code
logger.trace("Sending {} to {}", request.getMethod(), request.getURI()); logger.trace("Sending {} to {}", request.getMethod(), request.getURI());
final CompletableFuture<HttpResult> futureResult = new CompletableFuture<>(); final CompletableFuture<ContentResponse> futureResult = new CompletableFuture<>();
request.send(new BufferingResponseListener() { request.send(new BufferingResponseListener() {
@NonNullByDefault({}) @NonNullByDefault({})
@Override @Override
public void onComplete(Result result) { public void onComplete(Result result) {
futureResult.complete(new HttpResult(result.getResponse().getStatus(), getContentAsString())); Response response = result.getResponse();
futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding()));
} }
}); });
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; 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);
}
}
} }