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