]> git.basschouten.com Git - openhab-addons.git/blob
e67cdabd0f6f5aac2929a818b9ddca834efa89f8
[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.debug("Code verification failed.", ex);
250             return false;
251         }
252
253         logger.debug("Code verification successful.");
254
255         trustSession();
256         return true;
257     }
258
259     private void addSessionHeaders(List<Pair<String, String>> headers) {
260         String scnt = session.getScnt();
261         if (scnt != null && !scnt.isEmpty()) {
262             headers.add(Pair.of("scnt", scnt));
263         }
264
265         String sessionId = session.getSessionId();
266         if (sessionId != null && !sessionId.isEmpty()) {
267             headers.add(Pair.of("X-Apple-ID-Session-Id", sessionId));
268         }
269     }
270
271     private @Nullable String getWebserviceUrl(String wsKey) {
272         try {
273             @SuppressWarnings("unchecked")
274             Map<String, Object> webservices = (@Nullable Map<String, Object>) data.get("webservices");
275             if (webservices == null) {
276                 return null;
277             }
278             if (webservices.get(wsKey) instanceof Map) {
279                 @SuppressWarnings("unchecked")
280                 Map<String, ?> wsMap = (@Nullable Map<String, ?>) webservices.get(wsKey);
281                 if (wsMap == null) {
282                     logger.error("Webservices result map has not expected format.");
283                     return null;
284                 }
285                 return (String) wsMap.get("url");
286             } else {
287                 logger.error("Webservices result map has not expected format.");
288                 return null;
289             }
290         } catch (ClassCastException e) {
291             logger.error("ClassCastException, map has not expected format.", e);
292             return null;
293         }
294     }
295
296     /**
297      * Establish trust for current session.
298      *
299      * @return {@code true} if successful.
300      *
301      * @throws IOException if I/O error occurred
302      * @throws InterruptedException if this request was interrupted
303      * @throws ICloudApiResponseException if the request failed (e.g. not OK HTTP return code)
304      *
305      */
306     public boolean trustSession() throws IOException, InterruptedException, ICloudApiResponseException {
307         List<Pair<String, String>> headers = getAuthHeaders();
308
309         addSessionHeaders(headers);
310         this.session.get(AUTH_ENDPOINT + "/2sv/trust", headers);
311         return authenticateWithToken();
312     }
313
314     /**
315      * Get access to find my iPhone service.
316      *
317      * @return Instance of {@link FindMyIPhoneServiceManager} for this session.
318      * @throws IOException if I/O error occurred
319      * @throws InterruptedException if this request was interrupted
320      */
321     public FindMyIPhoneServiceManager getDevices() throws IOException, InterruptedException {
322         String webserviceUrl = getWebserviceUrl("findme");
323         if (webserviceUrl != null) {
324             return new FindMyIPhoneServiceManager(this.session, webserviceUrl);
325         } else {
326             throw new IllegalStateException("Webservice URLs not set. Need to authenticate first.");
327         }
328     }
329 }