]> git.basschouten.com Git - openhab-addons.git/blob
303d348e774725ba424d61f267ecf70cb672594a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
7  * This program and the accompanying materials are made available under the
8  * terms of the Eclipse Public License 2.0 which is available at
9  * http://www.eclipse.org/legal/epl-2.0
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.mybmw.internal.handler.auth;
14
15 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.API_OAUTH_CONFIG;
16 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.APP_VERSIONS;
17 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.AUTHORIZATION_CODE;
18 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.AUTH_PROVIDER;
19 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.BRAND_BMW;
20 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.CHINA_LOGIN;
21 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.CHINA_PUBLIC_KEY;
22 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.EADRAX_SERVER_MAP;
23 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.LOGIN_NONCE;
24 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.OAUTH_ENDPOINT;
25 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.OCP_APIM_KEYS;
26 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_CHINA;
27 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_NORTH_AMERICA;
28 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.REGION_ROW;
29 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.USER_AGENT;
30 import static org.openhab.binding.mybmw.internal.utils.BimmerConstants.X_USER_AGENT;
31 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.AUTHORIZATION;
32 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CLIENT_ID;
33 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE;
34 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_CHALLENGE;
35 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_CHALLENGE_METHOD;
36 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CODE_VERIFIER;
37 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.CONTENT_TYPE_URL_ENCODED;
38 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.GRANT_TYPE;
39 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_ACP_SUBSCRIPTION_KEY;
40 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_BMW_CORRELATION_ID;
41 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_CORRELATION_ID;
42 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_IDENTITY_PROVIDER;
43 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.HEADER_X_USER_AGENT;
44 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.NONCE;
45 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.PASSWORD;
46 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.REDIRECT_URI;
47 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.RESPONSE_TYPE;
48 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.SCOPE;
49 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.STATE;
50 import static org.openhab.binding.mybmw.internal.utils.HTTPConstants.USERNAME;
51
52 import java.nio.charset.StandardCharsets;
53 import java.security.KeyFactory;
54 import java.security.MessageDigest;
55 import java.security.NoSuchAlgorithmException;
56 import java.security.PublicKey;
57 import java.security.spec.X509EncodedKeySpec;
58 import java.util.Base64;
59 import java.util.UUID;
60
61 import javax.crypto.Cipher;
62
63 import org.eclipse.jdt.annotation.NonNullByDefault;
64 import org.eclipse.jdt.annotation.Nullable;
65 import org.eclipse.jetty.client.HttpClient;
66 import org.eclipse.jetty.client.HttpResponseException;
67 import org.eclipse.jetty.client.api.ContentResponse;
68 import org.eclipse.jetty.client.api.Request;
69 import org.eclipse.jetty.client.util.StringContentProvider;
70 import org.eclipse.jetty.http.HttpHeader;
71 import org.eclipse.jetty.util.MultiMap;
72 import org.eclipse.jetty.util.UrlEncoded;
73 import org.openhab.binding.mybmw.internal.MyBMWBridgeConfiguration;
74 import org.openhab.binding.mybmw.internal.dto.auth.AuthQueryResponse;
75 import org.openhab.binding.mybmw.internal.dto.auth.AuthResponse;
76 import org.openhab.binding.mybmw.internal.dto.auth.ChinaPublicKeyResponse;
77 import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenExpiration;
78 import org.openhab.binding.mybmw.internal.dto.auth.ChinaTokenResponse;
79 import org.openhab.binding.mybmw.internal.handler.backend.JsonStringDeserializer;
80 import org.openhab.binding.mybmw.internal.utils.Constants;
81 import org.openhab.core.util.StringUtils;
82 import org.slf4j.Logger;
83 import org.slf4j.LoggerFactory;
84
85 /**
86  *
87  * requests the tokens for MyBMW API authorization
88  *
89  * thanks to bimmer_connected https://github.com/bimmerconnected/bimmer_connected
90  *
91  * @author Bernd Weymann - Initial contribution
92  * @author Martin Grassl - extracted from myBmwProxy
93  */
94 @NonNullByDefault
95 public class MyBMWTokenController {
96
97     private final Logger logger = LoggerFactory.getLogger(MyBMWTokenController.class);
98
99     private Token token = new Token();
100     private MyBMWBridgeConfiguration configuration;
101     private HttpClient httpClient;
102
103     public MyBMWTokenController(MyBMWBridgeConfiguration configuration, HttpClient httpClient) {
104         this.configuration = configuration;
105         this.httpClient = httpClient;
106     }
107
108     /**
109      * Gets new token if old one is expired or invalid. In case of error the token
110      * remains.
111      * So if token refresh fails the corresponding requests will also fail and
112      * update the Thing status accordingly.
113      *
114      * @return token
115      */
116     public Token getToken() {
117         if (!token.isValid()) {
118             boolean tokenUpdateSuccess = false;
119             switch (configuration.region) {
120                 case REGION_CHINA:
121                     tokenUpdateSuccess = updateTokenChina();
122                     break;
123                 case REGION_NORTH_AMERICA:
124                 case REGION_ROW:
125                     tokenUpdateSuccess = updateToken();
126                     break;
127                 default:
128                     logger.warn("Region {} not supported", configuration.region);
129                     break;
130             }
131             if (!tokenUpdateSuccess) {
132                 logger.warn("Authorization failed!");
133             }
134         }
135         return token;
136     }
137
138     /**
139      * Everything is caught by surrounding try catch
140      * - HTTP Exceptions
141      * - JSONSyntax Exceptions
142      * - potential NullPointer Exceptions
143      *
144      * @return true if the token was successfully updated
145      */
146     private synchronized boolean updateToken() {
147         try {
148             /*
149              * Step 1) Get basic values for further queries
150              */
151             String uuidString = UUID.randomUUID().toString();
152
153             String authValuesUrl = "https://" + EADRAX_SERVER_MAP.get(configuration.region) + API_OAUTH_CONFIG;
154             Request authValuesRequest = httpClient.newRequest(authValuesUrl);
155             authValuesRequest.header(HEADER_ACP_SUBSCRIPTION_KEY, OCP_APIM_KEYS.get(configuration.region));
156             authValuesRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW,
157                     APP_VERSIONS.get(configuration.region), configuration.region));
158             authValuesRequest.header(HEADER_X_IDENTITY_PROVIDER, AUTH_PROVIDER);
159             authValuesRequest.header(HEADER_X_CORRELATION_ID, uuidString);
160             authValuesRequest.header(HEADER_BMW_CORRELATION_ID, uuidString);
161
162             ContentResponse authValuesResponse = authValuesRequest.send();
163             if (authValuesResponse.getStatus() != 200) {
164                 throw new HttpResponseException("URL: " + authValuesRequest.getURI() + ", Error: "
165                         + authValuesResponse.getStatus() + ", Message: " + authValuesResponse.getContentAsString(),
166                         authValuesResponse);
167             }
168             AuthQueryResponse aqr = JsonStringDeserializer.deserializeString(authValuesResponse.getContentAsString(),
169                     AuthQueryResponse.class);
170
171             logger.trace("authQueryResponse: {}", aqr);
172
173             /*
174              * Step 2) Calculate values for oauth base parameters
175              */
176             String codeVerifier = generateCodeVerifier();
177             String codeChallenge = generateCodeChallenge(codeVerifier);
178             String state = generateState();
179
180             MultiMap<@Nullable String> baseParams = new MultiMap<>();
181             baseParams.put(CLIENT_ID, aqr.clientId);
182             baseParams.put(RESPONSE_TYPE, CODE);
183             baseParams.put(REDIRECT_URI, aqr.returnUrl);
184             baseParams.put(STATE, state);
185             baseParams.put(NONCE, LOGIN_NONCE);
186             baseParams.put(SCOPE, String.join(Constants.SPACE, aqr.scopes));
187             baseParams.put(CODE_CHALLENGE, codeChallenge);
188             baseParams.put(CODE_CHALLENGE_METHOD, "S256");
189
190             /**
191              * Step 3) Authorization with username and password
192              */
193             String loginUrl = aqr.gcdmBaseUrl + OAUTH_ENDPOINT;
194             Request loginRequest = httpClient.POST(loginUrl);
195
196             loginRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
197
198             MultiMap<@Nullable String> loginParams = new MultiMap<>(baseParams);
199             loginParams.put(GRANT_TYPE, AUTHORIZATION_CODE);
200             loginParams.put(USERNAME, configuration.userName);
201             loginParams.put(PASSWORD, configuration.password);
202             loginRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
203                     UrlEncoded.encode(loginParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
204             ContentResponse loginResponse = loginRequest.send();
205             if (loginResponse.getStatus() != 200) {
206                 throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: "
207                         + loginResponse.getStatus() + ", Message: " + loginResponse.getContentAsString(),
208                         loginResponse);
209             }
210             String authCode = getAuthCode(loginResponse.getContentAsString());
211
212             /**
213              * Step 4) Authorize with code
214              */
215             Request authRequest = httpClient.POST(loginUrl).followRedirects(false);
216             MultiMap<@Nullable String> authParams = new MultiMap<>(baseParams);
217             authParams.put(AUTHORIZATION, authCode);
218             authRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
219             authRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
220                     UrlEncoded.encode(authParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
221             ContentResponse authResponse = authRequest.send();
222             if (authResponse.getStatus() != 302) {
223                 throw new HttpResponseException("URL: " + authRequest.getURI() + ", Error: " + authResponse.getStatus()
224                         + ", Message: " + authResponse.getContentAsString(), authResponse);
225             }
226             String code = codeFromUrl(authResponse.getHeaders().get(HttpHeader.LOCATION));
227
228             /**
229              * Step 5) Request token
230              */
231             Request codeRequest = httpClient.POST(aqr.tokenEndpoint);
232             String basicAuth = "Basic "
233                     + Base64.getUrlEncoder().encodeToString((aqr.clientId + ":" + aqr.clientSecret).getBytes());
234             codeRequest.header(HttpHeader.CONTENT_TYPE, CONTENT_TYPE_URL_ENCODED);
235             codeRequest.header(AUTHORIZATION, basicAuth);
236
237             MultiMap<@Nullable String> codeParams = new MultiMap<>();
238             codeParams.put(CODE, code);
239             codeParams.put(CODE_VERIFIER, codeVerifier);
240             codeParams.put(REDIRECT_URI, aqr.returnUrl);
241             codeParams.put(GRANT_TYPE, AUTHORIZATION_CODE);
242             codeRequest.content(new StringContentProvider(CONTENT_TYPE_URL_ENCODED,
243                     UrlEncoded.encode(codeParams, StandardCharsets.UTF_8, false), StandardCharsets.UTF_8));
244             ContentResponse codeResponse = codeRequest.send();
245             if (codeResponse.getStatus() != 200) {
246                 throw new HttpResponseException("URL: " + codeRequest.getURI() + ", Error: " + codeResponse.getStatus()
247                         + ", Message: " + codeResponse.getContentAsString(), codeResponse);
248             }
249             AuthResponse ar = JsonStringDeserializer.deserializeString(codeResponse.getContentAsString(),
250                     AuthResponse.class);
251             token.setType(ar.tokenType);
252             token.setToken(ar.accessToken);
253             token.setExpiration(ar.expiresIn);
254             return true;
255         } catch (Exception e) {
256             logger.warn("Authorization Exception: {}", e.getMessage());
257         }
258         return false;
259     }
260
261     private String generateState() {
262         String stateBytes = StringUtils.getRandomAlphabetic(64).toLowerCase();
263         return Base64.getUrlEncoder().withoutPadding().encodeToString(stateBytes.getBytes());
264     }
265
266     private String generateCodeChallenge(String codeVerifier) throws NoSuchAlgorithmException {
267         MessageDigest digest = MessageDigest.getInstance("SHA-256");
268         byte[] hash = digest.digest(codeVerifier.getBytes(StandardCharsets.UTF_8));
269         return Base64.getUrlEncoder().withoutPadding().encodeToString(hash);
270     }
271
272     private String generateCodeVerifier() {
273         String verfifierBytes = StringUtils.getRandomAlphabetic(64).toLowerCase();
274         return Base64.getUrlEncoder().withoutPadding().encodeToString(verfifierBytes.getBytes());
275     }
276
277     private String getAuthCode(String response) {
278         String[] keys = response.split("&");
279         for (int i = 0; i < keys.length; i++) {
280             if (keys[i].startsWith(AUTHORIZATION)) {
281                 String authCode = keys[i].split("=")[1];
282                 authCode = authCode.split("\"")[0];
283                 return authCode;
284             }
285         }
286         return Constants.EMPTY;
287     }
288
289     private String codeFromUrl(String encodedUrl) {
290         final MultiMap<@Nullable String> tokenMap = new MultiMap<>();
291         UrlEncoded.decodeTo(encodedUrl, tokenMap, StandardCharsets.US_ASCII);
292         final StringBuilder codeFound = new StringBuilder();
293         tokenMap.forEach((key, value) -> {
294             if (value.size() > 0) {
295                 String val = value.get(0);
296                 if (key.endsWith(CODE) && (val != null)) {
297                     codeFound.append(val.toString());
298                 }
299             }
300         });
301         return codeFound.toString();
302     }
303
304     private synchronized boolean updateTokenChina() {
305         try {
306             /**
307              * Step 1) get public key
308              */
309             String publicKeyUrl = "https://" + EADRAX_SERVER_MAP.get(REGION_CHINA) + CHINA_PUBLIC_KEY;
310             Request oauthQueryRequest = httpClient.newRequest(publicKeyUrl);
311             oauthQueryRequest.header(HttpHeader.USER_AGENT, USER_AGENT);
312             oauthQueryRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW,
313                     APP_VERSIONS.get(configuration.region), configuration.region));
314             ContentResponse publicKeyResponse = oauthQueryRequest.send();
315             if (publicKeyResponse.getStatus() != 200) {
316                 throw new HttpResponseException("URL: " + oauthQueryRequest.getURI() + ", Error: "
317                         + publicKeyResponse.getStatus() + ", Message: " + publicKeyResponse.getContentAsString(),
318                         publicKeyResponse);
319             }
320             ChinaPublicKeyResponse pkr = JsonStringDeserializer
321                     .deserializeString(publicKeyResponse.getContentAsString(), ChinaPublicKeyResponse.class);
322
323             /**
324              * Step 2) Encode password with public key
325              */
326             // https://www.baeldung.com/java-read-pem-file-keys
327             String publicKeyStr = pkr.data.value;
328             String publicKeyPEM = publicKeyStr.replace("-----BEGIN PUBLIC KEY-----", "")
329                     .replaceAll(System.lineSeparator(), "").replace("-----END PUBLIC KEY-----", "").replace("\\r", "")
330                     .replace("\\n", "").trim();
331             byte[] encoded = Base64.getDecoder().decode(publicKeyPEM);
332             X509EncodedKeySpec spec = new X509EncodedKeySpec(encoded);
333             KeyFactory kf = KeyFactory.getInstance("RSA");
334             PublicKey publicKey = kf.generatePublic(spec);
335             // https://www.thexcoders.net/java-ciphers-rsa/
336             Cipher cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding");
337             cipher.init(Cipher.ENCRYPT_MODE, publicKey);
338             byte[] encryptedBytes = cipher.doFinal(configuration.password.getBytes());
339             String encodedPassword = Base64.getEncoder().encodeToString(encryptedBytes);
340
341             /**
342              * Step 3) Send Auth with encoded password
343              */
344             String tokenUrl = "https://" + EADRAX_SERVER_MAP.get(REGION_CHINA) + CHINA_LOGIN;
345             Request loginRequest = httpClient.POST(tokenUrl);
346             loginRequest.header(HEADER_X_USER_AGENT, String.format(X_USER_AGENT, BRAND_BMW,
347                     APP_VERSIONS.get(configuration.region), configuration.region));
348             String jsonContent = "{ \"mobile\":\"" + configuration.userName + "\", \"password\":\"" + encodedPassword
349                     + "\"}";
350             loginRequest.content(new StringContentProvider(jsonContent));
351             ContentResponse tokenResponse = loginRequest.send();
352             if (tokenResponse.getStatus() != 200) {
353                 throw new HttpResponseException("URL: " + loginRequest.getURI() + ", Error: "
354                         + tokenResponse.getStatus() + ", Message: " + tokenResponse.getContentAsString(),
355                         tokenResponse);
356             }
357             String authCode = getAuthCode(tokenResponse.getContentAsString());
358
359             /**
360              * Step 4) Decode access token
361              */
362             ChinaTokenResponse cat = JsonStringDeserializer.deserializeString(authCode, ChinaTokenResponse.class);
363             String token = cat.data.accessToken;
364             // https://www.baeldung.com/java-jwt-token-decode
365             String[] chunks = token.split("\\.");
366             String tokenJwtDecodeStr = new String(Base64.getUrlDecoder().decode(chunks[1]));
367             ChinaTokenExpiration cte = JsonStringDeserializer.deserializeString(tokenJwtDecodeStr,
368                     ChinaTokenExpiration.class);
369             Token t = new Token();
370             t.setToken(token);
371             t.setType(cat.data.tokenType);
372             t.setExpirationTotal(cte.exp);
373             return true;
374         } catch (Exception e) {
375             logger.warn("Authorization Exception: {}", e.getMessage());
376         }
377         return false;
378     }
379 }