]> git.basschouten.com Git - openhab-addons.git/blob
74ede525e40593519991680feb8e5f3144b9a636
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2022 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.linky.internal.api;
14
15 import java.net.CookieStore;
16 import java.net.HttpCookie;
17 import java.net.URI;
18 import java.time.LocalDate;
19 import java.time.format.DateTimeFormatter;
20 import java.util.Objects;
21 import java.util.concurrent.ExecutionException;
22 import java.util.concurrent.TimeoutException;
23 import java.util.regex.Matcher;
24 import java.util.regex.Pattern;
25
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jetty.client.HttpClient;
28 import org.eclipse.jetty.client.api.ContentResponse;
29 import org.eclipse.jetty.client.util.FormContentProvider;
30 import org.eclipse.jetty.client.util.StringContentProvider;
31 import org.eclipse.jetty.http.HttpHeader;
32 import org.eclipse.jetty.util.Fields;
33 import org.jsoup.Jsoup;
34 import org.jsoup.nodes.Document;
35 import org.jsoup.nodes.Element;
36 import org.openhab.binding.linky.internal.LinkyConfiguration;
37 import org.openhab.binding.linky.internal.LinkyException;
38 import org.openhab.binding.linky.internal.dto.AuthData;
39 import org.openhab.binding.linky.internal.dto.AuthResult;
40 import org.openhab.binding.linky.internal.dto.ConsumptionReport;
41 import org.openhab.binding.linky.internal.dto.ConsumptionReport.Consumption;
42 import org.openhab.binding.linky.internal.dto.PrmInfo;
43 import org.openhab.binding.linky.internal.dto.UserInfo;
44 import org.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
46
47 import com.google.gson.Gson;
48 import com.google.gson.JsonSyntaxException;
49
50 /**
51  * {@link EnedisHttpApi} wraps the Enedis Webservice.
52  *
53  * @author GaĆ«l L'hopital - Initial contribution
54  */
55 @NonNullByDefault
56 public class EnedisHttpApi {
57     private static final DateTimeFormatter API_DATE_FORMAT = DateTimeFormatter.ofPattern("dd-MM-yyyy");
58     private static final String ENEDIS_DOMAIN = ".enedis.fr";
59     private static final String URL_APPS_LINCS = "https://apps.lincs" + ENEDIS_DOMAIN;
60     private static final String URL_MON_COMPTE = "https://mon-compte" + ENEDIS_DOMAIN;
61     private static final String URL_COMPTE_PART = URL_MON_COMPTE.replace("compte", "compte-particulier");
62     private static final String URL_ENEDIS_AUTHENTICATE = URL_APPS_LINCS + "/authenticate?target=" + URL_COMPTE_PART;
63     private static final String USER_INFO_URL = URL_APPS_LINCS + "/userinfos";
64     private static final String PRM_INFO_BASE_URL = URL_APPS_LINCS + "/mes-mesures/api/private/v1/personnes/";
65     private static final String PRM_INFO_URL = PRM_INFO_BASE_URL + "null/prms";
66     private static final String MEASURE_URL = PRM_INFO_BASE_URL
67             + "%s/prms/%s/donnees-%s?dateDebut=%s&dateFin=%s&mesuretypecode=CONS";
68     private static final URI COOKIE_URI = URI.create(URL_COMPTE_PART);
69     private static final Pattern REQ_PATTERN = Pattern.compile("ReqID%(.*?)%26");
70
71     private final Logger logger = LoggerFactory.getLogger(EnedisHttpApi.class);
72     private final Gson gson;
73     private final HttpClient httpClient;
74     private final CookieStore cookieStore;
75     private final LinkyConfiguration config;
76
77     private boolean connected = false;
78
79     public EnedisHttpApi(LinkyConfiguration config, Gson gson, HttpClient httpClient) {
80         this.gson = gson;
81         this.httpClient = httpClient;
82         this.config = config;
83         this.cookieStore = httpClient.getCookieStore();
84     }
85
86     public void initialize() throws LinkyException {
87         logger.debug("Starting login process for user : {}", config.username);
88
89         try {
90             addCookie(LinkyConfiguration.INTERNAL_AUTH_ID, config.internalAuthId);
91             logger.debug("Step 1 : getting authentification");
92             String data = getData(URL_ENEDIS_AUTHENTICATE);
93
94             logger.debug("Reception request SAML");
95             Document htmlDocument = Jsoup.parse(data);
96             Element el = htmlDocument.select("form").first();
97             Element samlInput = el.select("input[name=SAMLRequest]").first();
98
99             logger.debug("Step 2 : send SSO SAMLRequest");
100             ContentResponse result = httpClient.POST(el.attr("action"))
101                     .content(getFormContent("SAMLRequest", samlInput.attr("value"))).send();
102             if (result.getStatus() != 302) {
103                 throw new LinkyException("Connection failed step 2");
104             }
105
106             logger.debug("Get the location and the ReqID");
107             Matcher m = REQ_PATTERN.matcher(getLocation(result));
108             if (!m.find()) {
109                 throw new LinkyException("Unable to locate ReqId in header");
110             }
111
112             String reqId = m.group(1);
113             String authenticateUrl = URL_MON_COMPTE
114                     + "/auth/json/authenticate?realm=/enedis&forward=true&spEntityID=SP-ODW-PROD&goto=/auth/SSOPOST/metaAlias/enedis/providerIDP?ReqID%"
115                     + reqId + "%26index%3Dnull%26acsURL%3D" + URL_APPS_LINCS
116                     + "/saml/SSO%26spEntityID%3DSP-ODW-PROD%26binding%3Durn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST&AMAuthCookie=";
117
118             logger.debug("Step 3 : auth1 - retrieve the template, thanks to cookie internalAuthId user is already set");
119             result = httpClient.POST(authenticateUrl).header("X-NoSession", "true").header("X-Password", "anonymous")
120                     .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous").send();
121             if (result.getStatus() != 200) {
122                 throw new LinkyException("Connection failed step 3 - auth1 : %s", result.getContentAsString());
123             }
124
125             AuthData authData = gson.fromJson(result.getContentAsString(), AuthData.class);
126             if (authData == null || authData.callbacks.size() < 2 || authData.callbacks.get(0).input.isEmpty()
127                     || authData.callbacks.get(1).input.isEmpty() || !config.username
128                             .equals(Objects.requireNonNull(authData.callbacks.get(0).input.get(0)).valueAsString())) {
129                 logger.debug("auth1 - invalid template for auth data: {}", result.getContentAsString());
130                 throw new LinkyException("Authentication error, the authentication_cookie is probably wrong");
131             }
132
133             authData.callbacks.get(1).input.get(0).value = config.password;
134             logger.debug("Step 4 : auth2 - send the auth data");
135             result = httpClient.POST(authenticateUrl).header(HttpHeader.CONTENT_TYPE, "application/json")
136                     .header("X-NoSession", "true").header("X-Password", "anonymous")
137                     .header("X-Requested-With", "XMLHttpRequest").header("X-Username", "anonymous")
138                     .content(new StringContentProvider(gson.toJson(authData))).send();
139             if (result.getStatus() != 200) {
140                 throw new LinkyException("Connection failed step 3 - auth2 : %s", result.getContentAsString());
141             }
142
143             AuthResult authResult = gson.fromJson(result.getContentAsString(), AuthResult.class);
144             if (authResult == null) {
145                 throw new LinkyException("Invalid authentication result data");
146             }
147
148             logger.debug("Add the tokenId cookie");
149             addCookie("enedisExt", authResult.tokenId);
150
151             logger.debug("Step 5 : retrieve the SAMLresponse");
152             data = getData(URL_MON_COMPTE + "/" + authResult.successUrl);
153             htmlDocument = Jsoup.parse(data);
154             el = htmlDocument.select("form").first();
155             samlInput = el.select("input[name=SAMLResponse]").first();
156
157             logger.debug("Step 6 : post the SAMLresponse to finish the authentication");
158             result = httpClient.POST(el.attr("action")).content(getFormContent("SAMLResponse", samlInput.attr("value")))
159                     .send();
160             if (result.getStatus() != 302) {
161                 throw new LinkyException("Connection failed step 5");
162             }
163             connected = true;
164         } catch (InterruptedException | TimeoutException | ExecutionException | JsonSyntaxException e) {
165             throw new LinkyException(e, "Error opening connection with Enedis webservice");
166         }
167     }
168
169     private String getLocation(ContentResponse response) {
170         return response.getHeaders().get(HttpHeader.LOCATION);
171     }
172
173     private void disconnect() throws LinkyException {
174         if (connected) {
175             logger.debug("Logout process");
176             connected = false;
177             try { // Three times in a row to get disconnected
178                 String location = getLocation(httpClient.GET(URL_APPS_LINCS + "/logout"));
179                 location = getLocation(httpClient.GET(location));
180                 getLocation(httpClient.GET(location));
181                 cookieStore.removeAll();
182             } catch (InterruptedException | ExecutionException | TimeoutException e) {
183                 throw new LinkyException(e, "Error while disconnecting from Enedis webservice");
184             }
185         }
186     }
187
188     public boolean isConnected() {
189         return connected;
190     }
191
192     public void dispose() throws LinkyException {
193         disconnect();
194     }
195
196     private void addCookie(String key, String value) {
197         HttpCookie cookie = new HttpCookie(key, value);
198         cookie.setDomain(ENEDIS_DOMAIN);
199         cookie.setPath("/");
200         cookieStore.add(COOKIE_URI, cookie);
201     }
202
203     private FormContentProvider getFormContent(String fieldName, String fieldValue) {
204         Fields fields = new Fields();
205         fields.put(fieldName, fieldValue);
206         return new FormContentProvider(fields);
207     }
208
209     private String getData(String url) throws LinkyException {
210         try {
211             ContentResponse result = httpClient.GET(url);
212             if (result.getStatus() != 200) {
213                 throw new LinkyException("Error requesting '%s' : %s", url, result.getContentAsString());
214             }
215             return result.getContentAsString();
216         } catch (InterruptedException | ExecutionException | TimeoutException e) {
217             throw new LinkyException(e, "Error getting url : '%s'", url);
218         }
219     }
220
221     public PrmInfo getPrmInfo() throws LinkyException {
222         if (!connected) {
223             initialize();
224         }
225         String data = getData(PRM_INFO_URL);
226         if (data.isEmpty()) {
227             throw new LinkyException("Requesting '%s' returned an empty response", PRM_INFO_URL);
228         }
229         try {
230             PrmInfo[] prms = gson.fromJson(data, PrmInfo[].class);
231             if (prms == null || prms.length < 1) {
232                 throw new LinkyException("Invalid prms data received");
233             }
234             return prms[0];
235         } catch (JsonSyntaxException e) {
236             logger.debug("invalid JSON response not matching PrmInfo[].class: {}", data);
237             throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", PRM_INFO_URL);
238         }
239     }
240
241     public UserInfo getUserInfo() throws LinkyException {
242         if (!connected) {
243             initialize();
244         }
245         String data = getData(USER_INFO_URL);
246         if (data.isEmpty()) {
247             throw new LinkyException("Requesting '%s' returned an empty response", USER_INFO_URL);
248         }
249         try {
250             return Objects.requireNonNull(gson.fromJson(data, UserInfo.class));
251         } catch (JsonSyntaxException e) {
252             logger.debug("invalid JSON response not matching UserInfo.class: {}", data);
253             throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", USER_INFO_URL);
254         }
255     }
256
257     private Consumption getMeasures(String userId, String prmId, LocalDate from, LocalDate to, String request)
258             throws LinkyException {
259         String url = String.format(MEASURE_URL, userId, prmId, request, from.format(API_DATE_FORMAT),
260                 to.format(API_DATE_FORMAT));
261         if (!connected) {
262             initialize();
263         }
264         String data = getData(url);
265         if (data.isEmpty()) {
266             throw new LinkyException("Requesting '%s' returned an empty response", url);
267         }
268         logger.trace("getData returned {}", data);
269         try {
270             ConsumptionReport report = gson.fromJson(data, ConsumptionReport.class);
271             if (report == null) {
272                 throw new LinkyException("No report data received");
273             }
274             return report.firstLevel.consumptions;
275         } catch (JsonSyntaxException e) {
276             logger.debug("invalid JSON response not matching ConsumptionReport.class: {}", data);
277             throw new LinkyException(e, "Requesting '%s' returned an invalid JSON response", url);
278         }
279     }
280
281     public Consumption getEnergyData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
282         return getMeasures(userId, prmId, from, to, "energie");
283     }
284
285     public Consumption getPowerData(String userId, String prmId, LocalDate from, LocalDate to) throws LinkyException {
286         return getMeasures(userId, prmId, from, to, "pmax");
287     }
288 }