]> git.basschouten.com Git - openhab-addons.git/blob
511620192942c5d9e24da28273ec000136d8eed5
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.icloud.internal;
14
15 import java.io.IOException;
16 import java.util.ArrayList;
17 import java.util.HashMap;
18 import java.util.List;
19 import java.util.Map;
20 import java.util.UUID;
21
22 import org.eclipse.jdt.annotation.NonNullByDefault;
23 import org.eclipse.jdt.annotation.Nullable;
24 import org.openhab.binding.icloud.internal.utilities.JsonUtils;
25 import org.openhab.binding.icloud.internal.utilities.ListUtil;
26 import org.openhab.binding.icloud.internal.utilities.Pair;
27 import org.openhab.core.storage.Storage;
28 import org.slf4j.Logger;
29 import org.slf4j.LoggerFactory;
30
31 /**
32  *
33  * Class to access Apple iCloud API.
34  *
35  * The implementation of this class is inspired by https://github.com/picklepete/pyicloud.
36  *
37  * @author Simon Spielmann - Initial contribution
38  */
39 @NonNullByDefault
40 public class ICloudService {
41
42     /**
43      *
44      */
45     private static final String ICLOUD_CLIENT_ID = "d39ba9916b7251055b22c7f910e2ea796ee65e98b2ddecea8f5dde8d9d1a815d";
46
47     private final Logger logger = LoggerFactory.getLogger(ICloudService.class);
48
49     private static final String AUTH_ENDPOINT = "https://idmsa.apple.com/appleauth/auth";
50
51     private static final String HOME_ENDPOINT = "https://www.icloud.com";
52
53     private static final String SETUP_ENDPOINT = "https://setup.icloud.com/setup/ws/1";
54
55     private String appleId;
56
57     private String password;
58
59     private String clientId;
60
61     private Map<String, Object> data = new HashMap<>();
62
63     private ICloudSession session;
64
65     /**
66      *
67      * The constructor.
68      *
69      * @param appleId Apple id (e-mail address) for authentication
70      * @param password Password used for authentication
71      * @param stateStorage Storage to save authentication state
72      */
73     public ICloudService(String appleId, String password, Storage<String> stateStorage) {
74         this.appleId = appleId;
75         this.password = password;
76         this.clientId = "auth-" + UUID.randomUUID().toString().toLowerCase();
77
78         this.session = new ICloudSession(stateStorage);
79         this.session.setDefaultHeaders(Pair.of("Origin", HOME_ENDPOINT), Pair.of("Referer", HOME_ENDPOINT + "/"));
80     }
81
82     /**
83      * Initiate authentication
84      *
85      * @param forceRefresh Force a new authentication
86      * @return {@code true} if authentication was successful
87      * @throws IOException if I/O error occurred
88      * @throws InterruptedException if request was interrupted
89      */
90     public boolean authenticate(boolean forceRefresh) throws IOException, InterruptedException {
91         boolean loginSuccessful = false;
92         if (this.session.getSessionToken() != null && !forceRefresh) {
93             try {
94                 this.data = validateToken();
95                 logger.debug("Token is valid.");
96                 loginSuccessful = true;
97             } catch (ICloudApiResponseException ex) {
98                 logger.debug("Token is not valid. Attemping new login.", ex);
99             }
100         }
101
102         if (!loginSuccessful) {
103             logger.debug("Authenticating as {}...", this.appleId);
104
105             Map<String, Object> requestBody = new HashMap<>();
106             requestBody.put("accountName", this.appleId);
107             requestBody.put("password", this.password);
108             requestBody.put("rememberMe", true);
109             if (session.hasToken()) {
110                 requestBody.put("trustTokens", new String[] { this.session.getTrustToken() });
111             } else {
112                 requestBody.put("trustTokens", new String[0]);
113             }
114
115             List<Pair<String, String>> headers = getAuthHeaders();
116
117             try {
118                 this.session.post(AUTH_ENDPOINT + "/signin?isRememberMeEnabled=true", JsonUtils.toJson(requestBody),
119                         headers);
120             } catch (ICloudApiResponseException ex) {
121                 return false;
122             }
123         }
124         return authenticateWithToken();
125     }
126
127     /**
128      * Try authentication with stored session token. Returns {@code true} if authentication was successful.
129      *
130      * @return {@code true} if authentication was successful
131      *
132      * @throws IOException if I/O error occurred
133      * @throws InterruptedException if this request was interrupted
134      *
135      */
136     public boolean authenticateWithToken() throws IOException, InterruptedException {
137         Map<String, Object> requestBody = new HashMap<>();
138
139         String accountCountry = session.getAccountCountry();
140         if (accountCountry != null) {
141             requestBody.put("accountCountryCode", accountCountry);
142         }
143
144         String sessionToken = session.getSessionToken();
145         if (sessionToken != null) {
146             requestBody.put("dsWebAuthToken", sessionToken);
147         }
148
149         requestBody.put("extended_login", true);
150
151         if (session.hasToken()) {
152             String token = session.getTrustToken();
153             if (token != null) {
154                 requestBody.put("trustToken", token);
155             }
156         } else {
157             requestBody.put("trustToken", "");
158         }
159
160         try {
161             @Nullable
162             Map<String, Object> localSessionData = JsonUtils
163                     .toMap(session.post(SETUP_ENDPOINT + "/accountLogin", JsonUtils.toJson(requestBody), null));
164             if (localSessionData != null) {
165                 data = localSessionData;
166             }
167         } catch (ICloudApiResponseException ex) {
168             logger.debug("Invalid authentication.");
169             return false;
170         }
171         return true;
172     }
173
174     /**
175      * @param pair
176      * @return
177      */
178     private List<Pair<String, String>> getAuthHeaders() {
179         return new ArrayList<>(List.of(Pair.of("Accept", "*/*"), Pair.of("Content-Type", "application/json"),
180                 Pair.of("X-Apple-OAuth-Client-Id", ICLOUD_CLIENT_ID),
181                 Pair.of("X-Apple-OAuth-Client-Type", "firstPartyAuth"),
182                 Pair.of("X-Apple-OAuth-Redirect-URI", HOME_ENDPOINT),
183                 Pair.of("X-Apple-OAuth-Require-Grant-Code", "true"),
184                 Pair.of("X-Apple-OAuth-Response-Mode", "web_message"), Pair.of("X-Apple-OAuth-Response-Type", "code"),
185                 Pair.of("X-Apple-OAuth-State", this.clientId), Pair.of("X-Apple-Widget-Key", ICLOUD_CLIENT_ID)));
186     }
187
188     private Map<String, Object> validateToken() throws IOException, InterruptedException, ICloudApiResponseException {
189         logger.debug("Checking session token validity");
190         String result = session.post(SETUP_ENDPOINT + "/validate", null, null);
191         logger.debug("Session token is still valid");
192
193         @Nullable
194         Map<String, Object> localSessionData = JsonUtils.toMap(result);
195         if (localSessionData == null) {
196             throw new IOException("Unable to create data object from json response");
197         }
198         return localSessionData;
199     }
200
201     /**
202      * Checks if 2-FA authentication is required.
203      *
204      * @return {@code true} if 2-FA authentication ({@link #validate2faCode(String)}) is required.
205      */
206     public boolean requires2fa() {
207         if (this.data.containsKey("dsInfo")) {
208             @SuppressWarnings("unchecked")
209             Map<String, Object> dsInfo = (@Nullable Map<String, Object>) this.data.get("dsInfo");
210             if (dsInfo != null && ((Double) dsInfo.getOrDefault("hsaVersion", "0")) == 2.0) {
211                 return (this.data.containsKey("hsaChallengeRequired")
212                         && ((Boolean) this.data.getOrDefault("hsaChallengeRequired", Boolean.FALSE)
213                                 || !isTrustedSession()));
214             }
215         }
216         return false;
217     }
218
219     /**
220      * Checks if session is trusted.
221      *
222      * @return {@code true} if session is trusted. Call {@link #trustSession()} if not.
223      */
224     public boolean isTrustedSession() {
225         return (Boolean) this.data.getOrDefault("hsaTrustedBrowser", Boolean.FALSE);
226     }
227
228     /**
229      * Provides 2-FA code to establish trusted session.
230      *
231      * @param code Code given by user for 2-FA authentication.
232      * @return {@code true} if code was accepted
233      * @throws IOException if I/O error occurred
234      * @throws InterruptedException if this request was interrupted
235      * @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
236      */
237     public boolean validate2faCode(String code) throws IOException, InterruptedException, ICloudApiResponseException {
238         Map<String, Object> requestBody = Map.of("securityCode", Map.of("code", code));
239
240         List<Pair<String, String>> headers = ListUtil.replaceEntries(getAuthHeaders(),
241                 List.of(Pair.of("Accept", "application/json")));
242
243         addSessionHeaders(headers);
244
245         try {
246             this.session.post(AUTH_ENDPOINT + "/verify/trusteddevice/securitycode", JsonUtils.toJson(requestBody),
247                     headers);
248         } catch (ICloudApiResponseException ex) {
249             logger.trace("Exception on code verification with HTTP status {}. Verification might still be successful.",
250                     ex.getStatusCode(), ex);
251             // iCloud API returns different 4xx error codes even if validation is successful
252             // currently 400 seems to show that verification "really" failed.
253             if (ex.getStatusCode() == 400 || ex.getStatusCode() >= 500) {
254                 this.logger.debug("Verification failed with HTTP status {}.", ex.getStatusCode());
255                 return false;
256             }
257         }
258
259         logger.debug("Code verification successful.");
260
261         trustSession();
262         return true;
263     }
264
265     private void addSessionHeaders(List<Pair<String, String>> headers) {
266         String scnt = session.getScnt();
267         if (scnt != null && !scnt.isEmpty()) {
268             headers.add(Pair.of("scnt", scnt));
269         }
270
271         String sessionId = session.getSessionId();
272         if (sessionId != null && !sessionId.isEmpty()) {
273             headers.add(Pair.of("X-Apple-ID-Session-Id", sessionId));
274         }
275     }
276
277     private @Nullable String getWebserviceUrl(String wsKey) {
278         try {
279             @SuppressWarnings("unchecked")
280             Map<String, Object> webservices = (@Nullable Map<String, Object>) data.get("webservices");
281             if (webservices == null) {
282                 return null;
283             }
284             if (webservices.get(wsKey) instanceof Map) {
285                 @SuppressWarnings("unchecked")
286                 Map<String, ?> wsMap = (@Nullable Map<String, ?>) webservices.get(wsKey);
287                 if (wsMap == null) {
288                     logger.error("Webservices result map has not expected format.");
289                     return null;
290                 }
291                 return (String) wsMap.get("url");
292             } else {
293                 logger.error("Webservices result map has not expected format.");
294                 return null;
295             }
296         } catch (ClassCastException e) {
297             logger.error("ClassCastException, map has not expected format.", e);
298             return null;
299         }
300     }
301
302     /**
303      * Establish trust for current session.
304      *
305      * @return {@code true} if successful.
306      *
307      * @throws IOException if I/O error occurred
308      * @throws InterruptedException if this request was interrupted
309      * @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
310      *
311      */
312     public boolean trustSession() throws IOException, InterruptedException, ICloudApiResponseException {
313         List<Pair<String, String>> headers = getAuthHeaders();
314
315         addSessionHeaders(headers);
316         this.session.get(AUTH_ENDPOINT + "/2sv/trust", headers);
317         return authenticateWithToken();
318     }
319
320     /**
321      * Get access to find my iPhone service.
322      *
323      * @return Instance of {@link FindMyIPhoneServiceManager} for this session.
324      * @throws IOException if I/O error occurred
325      * @throws InterruptedException if this request was interrupted
326      */
327     public FindMyIPhoneServiceManager getDevices() throws IOException, InterruptedException {
328         String webserviceUrl = getWebserviceUrl("findme");
329         if (webserviceUrl != null) {
330             return new FindMyIPhoneServiceManager(this.session, webserviceUrl);
331         } else {
332             throw new IllegalStateException("Webservice URLs not set. Need to authenticate first.");
333         }
334     }
335 }