From d92e9fcf9e2c6bd8a3760666aa795c8ab671b72e Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Tue, 31 Aug 2021 10:46:44 -0700 Subject: [PATCH 1/9] change MyQ binding to use now required oAuth for authentication Signed-off-by: Dan Cunningham --- bundles/org.openhab.binding.myq/NOTICE | 7 + bundles/org.openhab.binding.myq/pom.xml | 8 + .../src/main/feature/feature.xml | 1 + .../myq/internal/MyQHandlerFactory.java | 8 +- .../internal/handler/MyQAccountHandler.java | 377 ++++++++++++++---- 5 files changed, 318 insertions(+), 83 deletions(-) diff --git a/bundles/org.openhab.binding.myq/NOTICE b/bundles/org.openhab.binding.myq/NOTICE index 38d625e34923..3e2c49e0050b 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 \ No newline at end of file diff --git a/bundles/org.openhab.binding.myq/pom.xml b/bundles/org.openhab.binding.myq/pom.xml index 564dd9ca217c..a94ba5262dcb 100644 --- a/bundles/org.openhab.binding.myq/pom.xml +++ b/bundles/org.openhab.binding.myq/pom.xml @@ -14,4 +14,12 @@ openHAB Add-ons :: Bundles :: MyQ Binding + + + org.jsoup + jsoup + 1.8.3 + provided + + diff --git a/bundles/org.openhab.binding.myq/src/main/feature/feature.xml b/bundles/org.openhab.binding.myq/src/main/feature/feature.xml index 60382373bb63..c5fabb87e6b1 100644 --- a/bundles/org.openhab.binding.myq/src/main/feature/feature.xml +++ b/bundles/org.openhab.binding.myq/src/main/feature/feature.xml @@ -4,6 +4,7 @@ openhab-runtime-base + mvn:org.jsoup/jsoup/1.8.3 mvn:org.openhab.addons.bundles/org.openhab.binding.myq/${project.version} diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java index 2d01eee7788a..d3d151cbec59 100644 --- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java +++ b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/MyQHandlerFactory.java @@ -20,6 +20,7 @@ import org.openhab.binding.myq.internal.handler.MyQAccountHandler; import org.openhab.binding.myq.internal.handler.MyQGarageDoorHandler; import org.openhab.binding.myq.internal.handler.MyQLampHandler; +import org.openhab.core.auth.client.oauth2.OAuthFactory; import org.openhab.core.io.net.http.HttpClientFactory; import org.openhab.core.thing.Bridge; import org.openhab.core.thing.Thing; @@ -41,10 +42,13 @@ @Component(configurationPid = "binding.myq", service = ThingHandlerFactory.class) public class MyQHandlerFactory extends BaseThingHandlerFactory { private final HttpClient httpClient; + private OAuthFactory oAuthFactory; @Activate - public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory) { + public MyQHandlerFactory(final @Reference HttpClientFactory httpClientFactory, + final @Reference OAuthFactory oAuthFactory) { this.httpClient = httpClientFactory.getCommonHttpClient(); + this.oAuthFactory = oAuthFactory; } @Override @@ -57,7 +61,7 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { ThingTypeUID thingTypeUID = thing.getThingTypeUID(); if (THING_TYPE_ACCOUNT.equals(thingTypeUID)) { - return new MyQAccountHandler((Bridge) thing, httpClient); + return new MyQAccountHandler((Bridge) thing, httpClient, oAuthFactory); } if (THING_TYPE_GARAGEDOOR.equals(thingTypeUID)) { diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java index f9dc5f11bcb0..e4528622982f 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,23 @@ * @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"; + /* + * 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); @@ -73,7 +114,6 @@ public class MyQAccountHandler extends BaseBridgeHandler { .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).create(); private @Nullable Future normalPollFuture; private @Nullable Future rapidPollFuture; - private @Nullable String securityToken; private @Nullable AccountDTO account; private @Nullable DevicesDTO devicesCache; private Integer normalRefreshSeconds = 60; @@ -82,9 +122,13 @@ public class MyQAccountHandler extends BaseBridgeHandler { private String password = ""; private String userAgent = ""; - public MyQAccountHandler(Bridge bridge, HttpClient httpClient) { + private final OAuthClientService oAuthService; + + public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) { super(bridge); this.httpClient = httpClient; + this.oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL, + LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false); } @Override @@ -98,8 +142,7 @@ 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(5); updateStatus(ThingStatus.UNKNOWN); restartPolls(false); } @@ -125,6 +168,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 * @@ -135,17 +183,19 @@ public void sendAction(String serialNumber, String action) { 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 | IOException | OAuthException | ExecutionException + | OAuthResponseException e) { + logger.debug("Could not send action", e); } } } @@ -203,49 +253,90 @@ private void rapidPoll() { } private synchronized void fetchData() { + boolean validToken = false; + try { + validToken = oAuthService.getAccessTokenResponse() != null; + } catch (OAuthException | IOException | OAuthResponseException e) { + logger.debug("error with oAuth service, attempting login again", e); + } + try { - if (securityToken == null) { + if (!validToken) { login(); - if (securityToken != null) { - getAccount(); - } } - if (securityToken != null) { - getDevices(); + if (account == null) { + getAccount(); } + getDevices(); + } catch (TimeoutException | IOException | OAuthException | ExecutionException | OAuthResponseException e) { + logger.debug("MyQ communication error", e); + updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); + } catch (MyQAuthenticationException 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(); - } + private void login() throws InterruptedException, IOException, OAuthException, ExecutionException, + OAuthResponseException, TimeoutException, MyQAuthenticationException { + + // make sure we have a fresh session + httpClient.getCookieStore().removeAll(); + + 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 IOException("Coul not load login page"); + } + + 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"); } + + logger.debug("Login Response URI {}", location); + + // finally complete the oAuth flow and retrieve a JSON oAuth token response + ContentResponse tokenResponse = getLoginToken(location, codeVerifier); + String loginToken = tokenResponse.getContentAsString(); + logger.debug("Login Token response {}", loginToken); + + AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class); + if (accessTokenResponse == null) { + throw new MyQAuthenticationException("Could not parse token response"); + } + oAuthService.importAccessTokenResponse(accessTokenResponse); } - 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, IOException, OAuthException, ExecutionException, OAuthResponseException { + 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, IOException, OAuthException, ExecutionException, OAuthResponseException { 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); + 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); if (devices != null) { devicesCache = devices; devices.items.forEach(device -> { @@ -263,44 +354,41 @@ private void getDevices() throws InterruptedException { } } - 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); - } - 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) { - futureResult.complete(new HttpResult(result.getResponse().getStatus(), getContentAsString())); - } - }); - HttpResult result = futureResult.get(); - logger.trace("Account Response - status: {} content: {}", result.responseCode, result.content); - return result; - } catch (ExecutionException e) { - return new HttpResult(0, e.getMessage()); + private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content, + @Nullable String contentType) + throws InterruptedException, IOException, OAuthException, ExecutionException, OAuthResponseException { + AccessTokenResponse tokenResponse = oAuthService.getAccessTokenResponse(); + if (tokenResponse == null) { + throw new OAuthException("unable to get accessToken"); + } + Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS) + .header("Authorization", tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken()); + 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())); + } + }); + ContentResponse result = futureResult.get(); + logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString()); + return result; } @Nullable - private T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class classOfT) { - if (HttpStatus.isSuccess(result.responseCode)) { + private T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class classOfT) { + 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); @@ -309,25 +397,116 @@ private T parseResultAndUpdateStatus(HttpResult result, Gson parser, Class 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(); + logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString()); + return response; + } catch (URISyntaxException e) { + throw new ExecutionException(e.getCause()); } } @@ -341,4 +520,40 @@ 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); + } + } + + class MyQAuthenticationException extends Exception { + private static final long serialVersionUID = 1L; + + public MyQAuthenticationException(String message) { + super(message); + } + } } From 221e2c4529794d5c8f6e4dabfe4eef3c4cfa0e14 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Tue, 31 Aug 2021 19:52:37 -0700 Subject: [PATCH 2/9] Clean up error handling Signed-off-by: Dan Cunningham --- .../internal/handler/MyQAccountHandler.java | 209 +++++++++++------- 1 file changed, 129 insertions(+), 80 deletions(-) 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 e4528622982f..1bc38ee46491 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 @@ -142,7 +142,7 @@ public void initialize() { username = config.username; password = config.password; // MyQ can get picky about blocking user agents apparently - userAgent = MyQAccountHandler.randomString(5); + userAgent = MyQAccountHandler.randomString(20); updateStatus(ThingStatus.UNKNOWN); restartPolls(false); } @@ -180,6 +180,11 @@ public void onAccessTokenResponse(AccessTokenResponse tokenResponse) { * @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 { @@ -193,8 +198,7 @@ HttpMethod.PUT, new StringContentProvider(gsonLowerCase.toJson(new ActionDTO(act } else { logger.debug("Failed to send action {} : {}", action, response.getContentAsString()); } - } catch (InterruptedException | IOException | OAuthException | ExecutionException - | OAuthResponseException e) { + } catch (InterruptedException | MyQCommunicationException | MyQAuthenticationException e) { logger.debug("Could not send action", e); } } @@ -253,22 +257,12 @@ private void rapidPoll() { } private synchronized void fetchData() { - boolean validToken = false; - try { - validToken = oAuthService.getAccessTokenResponse() != null; - } catch (OAuthException | IOException | OAuthResponseException e) { - logger.debug("error with oAuth service, attempting login again", e); - } - try { - if (!validToken) { - login(); - } if (account == null) { getAccount(); } getDevices(); - } catch (TimeoutException | IOException | OAuthException | ExecutionException | OAuthResponseException e) { + } catch (MyQCommunicationException e) { logger.debug("MyQ communication error", e); updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage()); } catch (MyQAuthenticationException e) { @@ -279,56 +273,58 @@ private synchronized void fetchData() { } } - private void login() throws InterruptedException, IOException, OAuthException, ExecutionException, - OAuthResponseException, TimeoutException, MyQAuthenticationException { - + private AccessTokenResponse login() + throws InterruptedException, MyQCommunicationException, MyQAuthenticationException { // make sure we have a fresh session httpClient.getCookieStore().removeAll(); - String codeVerifier = generateCodeVerifier(); + try { + String codeVerifier = generateCodeVerifier(); - ContentResponse loginPageResponse = getLoginPage(codeVerifier); + 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(); + // 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 IOException("Coul not load login page"); - } + if (form == null || requestToken == null) { + throw new MyQCommunicationException("Could not load login page"); + } - String action = LOGIN_BASE_URL + form.attr("action"); + 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"); - } + // 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"); + } - logger.debug("Login Response URI {}", location); + logger.debug("Login Response URI {}", location); - // finally complete the oAuth flow and retrieve a JSON oAuth token response - ContentResponse tokenResponse = getLoginToken(location, codeVerifier); - String loginToken = tokenResponse.getContentAsString(); - logger.debug("Login Token response {}", loginToken); + // finally complete the oAuth flow and retrieve a JSON oAuth token response + ContentResponse tokenResponse = getLoginToken(location, codeVerifier); + String loginToken = tokenResponse.getContentAsString(); + logger.debug("Login Token response {}", loginToken); - AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class); - if (accessTokenResponse == null) { - throw new MyQAuthenticationException("Could not parse token response"); + AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class); + if (accessTokenResponse == null) { + throw new MyQAuthenticationException("Could not parse token response"); + } + oAuthService.importAccessTokenResponse(accessTokenResponse); + return accessTokenResponse; + } catch (IOException | ExecutionException | TimeoutException | OAuthException e) { + throw new MyQCommunicationException(e.getMessage()); } - oAuthService.importAccessTokenResponse(accessTokenResponse); } - private void getAccount() - throws InterruptedException, IOException, OAuthException, ExecutionException, OAuthResponseException { + 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, IOException, OAuthException, ExecutionException, OAuthResponseException { + private void getDevices() throws InterruptedException, MyQCommunicationException, MyQAuthenticationException { AccountDTO localAccount = account; if (localAccount == null) { return; @@ -337,35 +333,42 @@ private void getDevices() String.format("%s/v5.1/Accounts/%s/Devices", BASE_URL, localAccount.account.id), HttpMethod.GET, null, null); DevicesDTO devices = parseResultAndUpdateStatus(response, 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); - } + 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 ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content, @Nullable String contentType) - throws InterruptedException, IOException, OAuthException, ExecutionException, OAuthResponseException { - AccessTokenResponse tokenResponse = oAuthService.getAccessTokenResponse(); + throws InterruptedException, MyQCommunicationException, MyQAuthenticationException { + + AccessTokenResponse tokenResponse = null; + try { + tokenResponse = oAuthService.getAccessTokenResponse(); + } catch (OAuthException | IOException | OAuthResponseException e) { + // ignore error + } + if (tokenResponse == null) { - throw new OAuthException("unable to get accessToken"); + tokenResponse = login(); } + Request request = httpClient.newRequest(url).method(method).agent(userAgent).timeout(10, TimeUnit.SECONDS) - .header("Authorization", tokenResponse.getTokenType() + " " + tokenResponse.getAccessToken()); + .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 @@ -379,13 +382,18 @@ public void onComplete(Result result) { futureResult.complete(new HttpContentResponse(response, getContent(), getMediaType(), getEncoding())); } }); - ContentResponse result = futureResult.get(); - logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString()); - return result; + + try { + ContentResponse result = futureResult.get(); + logger.trace("Account Response - status: {} content: {}", result.getStatus(), result.getContentAsString()); + return result; + } catch (ExecutionException e) { + throw new MyQCommunicationException(e.getMessage()); + } } - @Nullable - private T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class classOfT) { + private T parseResultAndUpdateStatus(ContentResponse response, Gson parser, Class classOfT) + throws MyQCommunicationException { if (HttpStatus.isSuccess(response.getStatus())) { try { T responseObject = parser.fromJson(response.getContentAsString(), classOfT); @@ -394,27 +402,32 @@ private T parseResultAndUpdateStatus(ContentResponse response, Gson parser, 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 " + response.getContentAsString()); + throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString()); } } else if (response.getStatus() == HttpStatus.UNAUTHORIZED_401) { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, - "Unauthorized - Check Credentials"); + throw new MyQCommunicationException("Token was rejected for request"); } else { - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, + throw new MyQCommunicationException( "Invalid Response Code " + response.getStatus() + " : " + response.getContentAsString()); } - return null; } + /** + * 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 { - /* - * this returns the login page, so we can grab cookies to log in with - */ Request request = httpClient.newRequest(LOGIN_AUTHORIZE_URL) // .param("client_id", CLIENT_ID) // .param("code_challenge", generateCodeChallange(codeVerifier)) // @@ -432,6 +445,17 @@ private ContentResponse getLoginPage(String codeVerifier) } } + /** + * 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 { @@ -479,11 +503,18 @@ private String postLogin(String url, String requestToken, String returnURL) 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 { - /* - * this returns the login page, so we can grab cookies to log in with - */ try { Map params = parseLocationQuery(redirectLocation); @@ -549,6 +580,13 @@ private void setCookies(Request request) { } } + 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; @@ -556,4 +594,15 @@ 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); + } + } } From 724476a4de4e72ab96ce749da0842eba55c04db5 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Tue, 31 Aug 2021 20:24:44 -0700 Subject: [PATCH 3/9] Cleanup checkstyle errors Signed-off-by: Dan Cunningham --- .../binding/myq/internal/handler/MyQAccountHandler.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) 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 1bc38ee46491..f5171c6684cf 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 @@ -351,7 +351,6 @@ private void getDevices() throws InterruptedException, MyQCommunicationException private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable ContentProvider content, @Nullable String contentType) throws InterruptedException, MyQCommunicationException, MyQAuthenticationException { - AccessTokenResponse tokenResponse = null; try { tokenResponse = oAuthService.getAccessTokenResponse(); @@ -435,7 +434,7 @@ private ContentResponse getLoginPage(String codeVerifier) .param("redirect_uri", REDIRECT_URI) // .param("response_type", "code") // .param("scope", SCOPE) // - .agent("null").followRedirects(true); + .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()); From 1a6b9813d1b1d9c50791080140e3ee25bfc0f95d Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Tue, 31 Aug 2021 20:26:03 -0700 Subject: [PATCH 4/9] missing newline Signed-off-by: Dan Cunningham --- bundles/org.openhab.binding.myq/NOTICE | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/bundles/org.openhab.binding.myq/NOTICE b/bundles/org.openhab.binding.myq/NOTICE index 3e2c49e0050b..0ca708bef198 100644 --- a/bundles/org.openhab.binding.myq/NOTICE +++ b/bundles/org.openhab.binding.myq/NOTICE @@ -17,4 +17,4 @@ https://github.com/openhab/openhab-addons jsoup * License: MIT License * Project: https://jsoup.org/ -* Source: https://github.com/jhy/jsoup \ No newline at end of file +* Source: https://github.com/jhy/jsoup From 96c8c4072c5b6b0213372b487e797db7fe38bb1c Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Tue, 31 Aug 2021 20:44:18 -0700 Subject: [PATCH 5/9] Remove unused classes Signed-off-by: Dan Cunningham --- .../myq/internal/dto/LoginRequestDTO.java | 30 ------------------- .../myq/internal/dto/LoginResponseDTO.java | 22 -------------- 2 files changed, 52 deletions(-) delete mode 100644 bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java delete mode 100644 bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java deleted file mode 100644 index 8b2eaf54014c..000000000000 --- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginRequestDTO.java +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Copyright (c) 2010-2021 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.myq.internal.dto; - -/** - * The {@link LoginRequestDTO} entity from the MyQ API - * - * @author Dan Cunningham - Initial contribution - */ -public class LoginRequestDTO { - - public LoginRequestDTO(String username, String password) { - super(); - this.username = username; - this.password = password; - } - - public String username; - public String password; -} diff --git a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java deleted file mode 100644 index 2dfcd637d21b..000000000000 --- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/dto/LoginResponseDTO.java +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) 2010-2021 Contributors to the openHAB project - * - * See the NOTICE file(s) distributed with this work for additional - * information. - * - * This program and the accompanying materials are made available under the - * terms of the Eclipse Public License 2.0 which is available at - * http://www.eclipse.org/legal/epl-2.0 - * - * SPDX-License-Identifier: EPL-2.0 - */ -package org.openhab.binding.myq.internal.dto; - -/** - * The {@link LoginResponseDTO} entity from the MyQ API - * - * @author Dan Cunningham - Initial contribution - */ -public class LoginResponseDTO { - public String securityToken; -} From 3290da3412b445ec5f14ff94ecaee1a17bfc008a Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Thu, 2 Sep 2021 09:47:02 -0700 Subject: [PATCH 6/9] Add token listener Signed-off-by: Dan Cunningham --- .../binding/myq/internal/handler/MyQAccountHandler.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 f5171c6684cf..13490f9d388e 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 @@ -127,8 +127,9 @@ public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenR public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) { super(bridge); this.httpClient = httpClient; - this.oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL, + oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL, LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false); + oAuthService.addAccessTokenRefreshListener(this); } @Override From c982282a265e5e7963fff61f719169b4f1ad437b Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Thu, 2 Sep 2021 19:59:49 -0700 Subject: [PATCH 7/9] add a redirect limit...just in case Signed-off-by: Dan Cunningham --- .../binding/myq/internal/handler/MyQAccountHandler.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) 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 13490f9d388e..e141fddee5c1 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 @@ -102,6 +102,8 @@ public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenR 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 */ @@ -483,8 +485,8 @@ private String postLogin(String url, String requestToken, String returnURL) String location = null; - // follow redirects until we match our REDIRECT_URI - while (HttpStatus.isRedirection(response.getStatus())) { + // 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++) { logger.debug("Redirect Login: Code {} Response {}", response.getStatus(), response.getContentAsString()); String loc = response.getHeaders().get("location"); logger.debug("location string {}", loc); From a92487c6144ebd4416cffda9fe5517a82cf68d4f Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sat, 4 Sep 2021 11:26:30 -0700 Subject: [PATCH 8/9] Don't resue the oAuth service if we have been disosed or its closed. Reduce logging verbosity. Signed-off-by: Dan Cunningham --- .../internal/handler/MyQAccountHandler.java | 64 ++++++++++++++----- 1 file changed, 48 insertions(+), 16 deletions(-) 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 e141fddee5c1..e40cf8127a60 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 @@ -114,24 +114,24 @@ public class MyQAccountHandler extends BaseBridgeHandler implements AccessTokenR .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 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 = ""; - - private final OAuthClientService oAuthService; + // force login, even if we have a token + private boolean needsLogin = false; public MyQAccountHandler(Bridge bridge, HttpClient httpClient, final OAuthFactory oAuthFactory) { super(bridge); this.httpClient = httpClient; - oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL, - LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false); - oAuthService.addAccessTokenRefreshListener(this); + this.oAuthFactory = oAuthFactory; } @Override @@ -146,6 +146,7 @@ public void initialize() { password = config.password; // MyQ can get picky about blocking user agents apparently userAgent = MyQAccountHandler.randomString(20); + needsLogin = true; updateStatus(ThingStatus.UNKNOWN); restartPolls(false); } @@ -153,6 +154,9 @@ public void initialize() { @Override public void dispose() { stopPolls(); + if (oAuthService != null) { + oAuthService.close(); + } } @Override @@ -276,6 +280,14 @@ private synchronized void fetchData() { } } + /** + * 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 @@ -296,6 +308,7 @@ private AccessTokenResponse login() 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 @@ -304,18 +317,15 @@ private AccessTokenResponse login() throw new MyQAuthenticationException("Could not login with credentials"); } - logger.debug("Login Response URI {}", location); - // finally complete the oAuth flow and retrieve a JSON oAuth token response ContentResponse tokenResponse = getLoginToken(location, codeVerifier); String loginToken = tokenResponse.getContentAsString(); - logger.debug("Login Token response {}", loginToken); AccessTokenResponse accessTokenResponse = gsonLowerCase.fromJson(loginToken, AccessTokenResponse.class); if (accessTokenResponse == null) { throw new MyQAuthenticationException("Could not parse token response"); } - oAuthService.importAccessTokenResponse(accessTokenResponse); + getOAuthService().importAccessTokenResponse(accessTokenResponse); return accessTokenResponse; } catch (IOException | ExecutionException | TimeoutException | OAuthException e) { throw new MyQCommunicationException(e.getMessage()); @@ -355,14 +365,20 @@ private synchronized ContentResponse sendRequest(String url, HttpMethod method, @Nullable String contentType) throws InterruptedException, MyQCommunicationException, MyQAuthenticationException { AccessTokenResponse tokenResponse = null; - try { - tokenResponse = oAuthService.getAccessTokenResponse(); - } catch (OAuthException | IOException | OAuthResponseException e) { - // ignore error + // 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) @@ -487,9 +503,12 @@ private String postLogin(String url, String requestToken, String returnURL) // 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++) { - logger.debug("Redirect Login: Code {} Response {}", response.getStatus(), response.getContentAsString()); + String loc = response.getHeaders().get("location"); - logger.debug("location string {}", loc); + 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; @@ -536,13 +555,26 @@ private ContentResponse getLoginToken(String redirectLocation, String codeVerifi setCookies(request); ContentResponse response = request.send(); - logger.debug("Login Code {} Response {}", response.getStatus(), response.getContentAsString()); + if (logger.isTraceEnabled()) { + logger.trace("Login Code {} Response {}", response.getStatus(), response.getContentAsString()); + } return response; } catch (URISyntaxException e) { throw new ExecutionException(e.getCause()); } } + private OAuthClientService getOAuthService() { + OAuthClientService oAuthService = this.oAuthService; + if (oAuthService == null || oAuthService.isClosed()) { + oAuthService = oAuthFactory.createOAuthClientService(getThing().toString(), LOGIN_TOKEN_URL, + LOGIN_AUTHORIZE_URL, CLIENT_ID, CLIENT_SECRET, SCOPE, false); + oAuthService.addAccessTokenRefreshListener(this); + this.oAuthService = oAuthService; + } + return oAuthService; + } + private static String randomString(int length) { int low = 97; // a-z int high = 122; // A-Z From 50deca607da58b4b210f65a0b5062822f63f9b03 Mon Sep 17 00:00:00 2001 From: Dan Cunningham Date: Sun, 5 Sep 2021 12:19:03 -0700 Subject: [PATCH 9/9] Force login if we get a 401 response Signed-off-by: Dan Cunningham --- .../binding/myq/internal/handler/MyQAccountHandler.java | 3 +++ 1 file changed, 3 insertions(+) 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 e40cf8127a60..90dd033a9079 100644 --- a/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java +++ b/bundles/org.openhab.binding.myq/src/main/java/org/openhab/binding/myq/internal/handler/MyQAccountHandler.java @@ -273,6 +273,7 @@ private synchronized void fetchData() { 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) { @@ -427,6 +428,8 @@ private T parseResultAndUpdateStatus(ContentResponse response, Gson parser, throw new MyQCommunicationException("Invalid JSON Response " + response.getContentAsString()); } } 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 { throw new MyQCommunicationException(