]> git.basschouten.com Git - openhab-addons.git/blob
42b1fc7fb1407004a0ba890c979cd0cbdcb4360a
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2020 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.dlinksmarthome.internal;
14
15 import java.io.ByteArrayInputStream;
16 import java.io.ByteArrayOutputStream;
17 import java.io.IOException;
18 import java.io.StringWriter;
19 import java.net.URI;
20 import java.net.URISyntaxException;
21 import java.security.InvalidKeyException;
22 import java.security.NoSuchAlgorithmException;
23 import java.util.Iterator;
24 import java.util.concurrent.ExecutionException;
25 import java.util.concurrent.TimeUnit;
26 import java.util.concurrent.TimeoutException;
27
28 import javax.crypto.Mac;
29 import javax.crypto.spec.SecretKeySpec;
30 import javax.xml.parsers.DocumentBuilder;
31 import javax.xml.parsers.DocumentBuilderFactory;
32 import javax.xml.parsers.ParserConfigurationException;
33 import javax.xml.soap.MessageFactory;
34 import javax.xml.soap.MimeHeader;
35 import javax.xml.soap.MimeHeaders;
36 import javax.xml.soap.SOAPBody;
37 import javax.xml.soap.SOAPElement;
38 import javax.xml.soap.SOAPException;
39 import javax.xml.soap.SOAPMessage;
40 import javax.xml.transform.OutputKeys;
41 import javax.xml.transform.Transformer;
42 import javax.xml.transform.TransformerException;
43 import javax.xml.transform.TransformerFactory;
44 import javax.xml.transform.dom.DOMSource;
45 import javax.xml.transform.stream.StreamResult;
46
47 import org.eclipse.jetty.client.HttpClient;
48 import org.eclipse.jetty.client.api.ContentResponse;
49 import org.eclipse.jetty.client.api.Request;
50 import org.eclipse.jetty.client.util.BytesContentProvider;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53 import org.w3c.dom.Document;
54 import org.w3c.dom.Node;
55 import org.xml.sax.SAXException;
56
57 /**
58  * The {@link DLinkHNAPCommunication} is responsible for communicating with D-Link
59  * Smart Home devices using the HNAP interface.
60  *
61  * This abstract class handles login and authentication which is common between devices.
62  *
63  * Reverse engineered from Login.html and soapclient.js retrieved from the device.
64  *
65  * @author Mike Major - Initial contribution
66  */
67 public abstract class DLinkHNAPCommunication {
68
69     // SOAP actions
70     private static final String LOGIN_ACTION = "\"http://purenetworks.com/HNAP1/LOGIN\"";
71
72     // Strings used more than once
73     private static final String LOGIN = "LOGIN";
74     private static final String ACTION = "Action";
75     private static final String USERNAME = "Username";
76     private static final String LOGINPASSWORD = "LoginPassword";
77     private static final String CAPTCHA = "Captcha";
78     private static final String ADMIN = "Admin";
79     private static final String LOGINRESULT = "LOGINResult";
80     private static final String COOKIE = "Cookie";
81
82     /**
83      * HNAP XMLNS
84      */
85     protected static final String HNAP_XMLNS = "http://purenetworks.com/HNAP1";
86     /**
87      * The SOAP action HTML header
88      */
89     protected static final String SOAPACTION = "SOAPAction";
90     /**
91      * OK represents a successful action
92      */
93     protected static final String OK = "OK";
94
95     /**
96      * Use to log connection issues
97      */
98     private final Logger logger = LoggerFactory.getLogger(DLinkHNAPCommunication.class);
99
100     private URI uri;
101     private final HttpClient httpClient;
102     private final String pin;
103     private String privateKey;
104
105     private DocumentBuilder parser;
106     private SOAPMessage requestAction;
107     private SOAPMessage loginAction;
108
109     private HNAPStatus status = HNAPStatus.INITIALISED;
110
111     /**
112      * Indicates the status of the HNAP interface
113      *
114      */
115     protected enum HNAPStatus {
116         /**
117          * Ready to start communication with device
118          */
119         INITIALISED,
120         /**
121          * Successfully logged in to device
122          */
123         LOGGED_IN,
124         /**
125          * Problem communicating with device
126          */
127         COMMUNICATION_ERROR,
128         /**
129          * Internal error
130          */
131         INTERNAL_ERROR,
132         /**
133          * Error due to unsupported firmware
134          */
135         UNSUPPORTED_FIRMWARE,
136         /**
137          * Error due to invalid pin code
138          */
139         INVALID_PIN
140     }
141
142     /**
143      * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
144      * after construction.
145      *
146      * @param ipAddress
147      * @param pin
148      */
149     public DLinkHNAPCommunication(final String ipAddress, final String pin) {
150         this.pin = pin;
151
152         httpClient = new HttpClient();
153
154         try {
155             uri = new URI("http://" + ipAddress + "/HNAP1");
156             httpClient.start();
157
158             parser = DocumentBuilderFactory.newInstance().newDocumentBuilder();
159
160             final MessageFactory messageFactory = MessageFactory.newInstance();
161             requestAction = messageFactory.createMessage();
162             loginAction = messageFactory.createMessage();
163
164             buildRequestAction();
165             buildLoginAction();
166         } catch (final SOAPException e) {
167             logger.debug("DLinkHNAPCommunication - Internal error", e);
168             status = HNAPStatus.INTERNAL_ERROR;
169         } catch (final URISyntaxException e) {
170             logger.debug("DLinkHNAPCommunication - Internal error", e);
171             status = HNAPStatus.INTERNAL_ERROR;
172         } catch (final ParserConfigurationException e) {
173             logger.debug("DLinkHNAPCommunication - Internal error", e);
174             status = HNAPStatus.INTERNAL_ERROR;
175         } catch (final Exception e) {
176             // Thrown by httpClient.start()
177             logger.debug("DLinkHNAPCommunication - Internal error", e);
178             status = HNAPStatus.INTERNAL_ERROR;
179         }
180     }
181
182     /**
183      * Stop communicating with the device
184      */
185     public void dispose() {
186         try {
187             httpClient.stop();
188         } catch (final Exception e) {
189             // Ignored
190         }
191     }
192
193     /**
194      * This is the first SOAP message used in the login process and is used to retrieve
195      * the cookie, challenge and public key used for authentication.
196      *
197      * @throws SOAPException
198      */
199     private void buildRequestAction() throws SOAPException {
200         requestAction.getSOAPHeader().detachNode();
201         final SOAPBody soapBody = requestAction.getSOAPBody();
202         final SOAPElement soapBodyElem = soapBody.addChildElement(LOGIN, "", HNAP_XMLNS);
203         soapBodyElem.addChildElement(ACTION).addTextNode("request");
204         soapBodyElem.addChildElement(USERNAME).addTextNode(ADMIN);
205         soapBodyElem.addChildElement(LOGINPASSWORD);
206         soapBodyElem.addChildElement(CAPTCHA);
207
208         final MimeHeaders headers = requestAction.getMimeHeaders();
209         headers.addHeader(SOAPACTION, LOGIN_ACTION);
210
211         requestAction.saveChanges();
212     }
213
214     /**
215      * This is the second SOAP message used in the login process and uses a password derived
216      * from the challenge, public key and the device's pin code.
217      *
218      * @throws SOAPException
219      */
220     private void buildLoginAction() throws SOAPException {
221         loginAction.getSOAPHeader().detachNode();
222         final SOAPBody soapBody = loginAction.getSOAPBody();
223         final SOAPElement soapBodyElem = soapBody.addChildElement(LOGIN, "", HNAP_XMLNS);
224         soapBodyElem.addChildElement(ACTION).addTextNode("login");
225         soapBodyElem.addChildElement(USERNAME).addTextNode(ADMIN);
226         soapBodyElem.addChildElement(LOGINPASSWORD);
227         soapBodyElem.addChildElement(CAPTCHA);
228
229         final MimeHeaders headers = loginAction.getMimeHeaders();
230         headers.addHeader(SOAPACTION, LOGIN_ACTION);
231     }
232
233     /**
234      * Sets the password for the second login message based on the data received from the
235      * first login message. Also sets the private key used to generate the authentication header.
236      *
237      * @param challenge
238      * @param cookie
239      * @param publicKey
240      * @throws SOAPException
241      * @throws InvalidKeyException
242      * @throws NoSuchAlgorithmException
243      */
244     private void setAuthenticationData(final String challenge, final String cookie, final String publicKey)
245             throws SOAPException, InvalidKeyException, NoSuchAlgorithmException {
246         final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
247         loginHeaders.setHeader(COOKIE, "uid=" + cookie);
248
249         privateKey = hash(challenge, publicKey + pin);
250
251         final String password = hash(challenge, privateKey);
252
253         loginAction.getSOAPBody().getElementsByTagName(LOGINPASSWORD).item(0).setTextContent(password);
254         loginAction.saveChanges();
255     }
256
257     /**
258      * Used to hash the authentication data such as the login password and the authentication header
259      * for the detection message.
260      *
261      * @param data
262      * @param key
263      * @return The hashed data
264      * @throws NoSuchAlgorithmException
265      * @throws InvalidKeyException
266      */
267     private String hash(final String data, final String key) throws NoSuchAlgorithmException, InvalidKeyException {
268         final Mac mac = Mac.getInstance("HMACMD5");
269         final SecretKeySpec sKey = new SecretKeySpec(key.getBytes(), "ASCII");
270
271         mac.init(sKey);
272         final byte[] bytes = mac.doFinal(data.getBytes());
273
274         final StringBuilder hashBuf = new StringBuilder();
275         for (int i = 0; i < bytes.length; i++) {
276             final String hex = Integer.toHexString(0xFF & bytes[i]).toUpperCase();
277             if (hex.length() == 1) {
278                 hashBuf.append('0');
279             }
280             hashBuf.append(hex);
281         }
282
283         return hashBuf.toString();
284     }
285
286     /**
287      * Output unexpected responses to the debug log and sets the FIRMWARE error.
288      *
289      * @param message
290      * @param soapResponse
291      */
292     private void unexpectedResult(final String message, final Document soapResponse) {
293         logUnexpectedResult(message, soapResponse);
294
295         // Best guess when receiving unexpected responses
296         status = HNAPStatus.UNSUPPORTED_FIRMWARE;
297     }
298
299     /**
300      * Get the status of the HNAP interface
301      *
302      * @return the HNAP status
303      */
304     protected HNAPStatus getHNAPStatus() {
305         return status;
306     }
307
308     /**
309      * Sends the two login messages and stores the private key used to generate the
310      * authentication header required for actions.
311      *
312      * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
313      * after calling this method.
314      *
315      * @param timeout - Connection timeout in milliseconds
316      */
317     protected void login(final int timeout) {
318         if (status != HNAPStatus.INTERNAL_ERROR) {
319             try {
320                 Document soapResponse = sendReceive(requestAction, timeout);
321
322                 Node result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
323
324                 if (result != null && OK.equals(result.getTextContent())) {
325                     final Node challengeNode = soapResponse.getElementsByTagName("Challenge").item(0);
326                     final Node cookieNode = soapResponse.getElementsByTagName(COOKIE).item(0);
327                     final Node publicKeyNode = soapResponse.getElementsByTagName("PublicKey").item(0);
328
329                     if (challengeNode != null && cookieNode != null && publicKeyNode != null) {
330                         setAuthenticationData(challengeNode.getTextContent(), cookieNode.getTextContent(),
331                                 publicKeyNode.getTextContent());
332
333                         soapResponse = sendReceive(loginAction, timeout);
334                         result = soapResponse.getElementsByTagName(LOGINRESULT).item(0);
335
336                         if (result != null) {
337                             if ("success".equals(result.getTextContent())) {
338                                 status = HNAPStatus.LOGGED_IN;
339                             } else {
340                                 logger.debug("login - Check pin is correct");
341                                 // Assume pin code problem rather than a firmware change
342                                 status = HNAPStatus.INVALID_PIN;
343                             }
344                         } else {
345                             unexpectedResult("login - Unexpected login response", soapResponse);
346                         }
347                     } else {
348                         unexpectedResult("login - Unexpected request response", soapResponse);
349                     }
350                 } else {
351                     unexpectedResult("login - Unexpected request response", soapResponse);
352                 }
353             } catch (final InvalidKeyException e) {
354                 logger.debug("login - Internal error", e);
355                 status = HNAPStatus.INTERNAL_ERROR;
356             } catch (final NoSuchAlgorithmException e) {
357                 logger.debug("login - Internal error", e);
358                 status = HNAPStatus.INTERNAL_ERROR;
359             } catch (final Exception e) {
360                 // Assume there has been some problem trying to send one of the messages
361                 if (status != HNAPStatus.COMMUNICATION_ERROR) {
362                     logger.debug("login - Communication error", e);
363                     status = HNAPStatus.COMMUNICATION_ERROR;
364                 }
365             }
366         }
367     }
368
369     /**
370      * Sets the authentication headers for the action message. This should only be called
371      * after a successful login.
372      *
373      * Use {@link #getHNAPStatus()} to determine the status of the HNAP connection
374      * after calling this method.
375      *
376      * @param action - SOAP Action to add headers
377      */
378     protected void setAuthenticationHeaders(final SOAPMessage action) {
379         if (status == HNAPStatus.LOGGED_IN) {
380             try {
381                 final MimeHeaders loginHeaders = loginAction.getMimeHeaders();
382                 final MimeHeaders actionHeaders = action.getMimeHeaders();
383
384                 actionHeaders.setHeader(COOKIE, loginHeaders.getHeader(COOKIE)[0]);
385
386                 final String timeStamp = String.valueOf(System.currentTimeMillis() / 1000);
387                 final String auth = hash(timeStamp + actionHeaders.getHeader(SOAPACTION)[0], privateKey) + " "
388                         + timeStamp;
389                 actionHeaders.setHeader("HNAP_AUTH", auth);
390
391                 action.saveChanges();
392             } catch (final InvalidKeyException e) {
393                 logger.debug("setAuthenticationHeaders - Internal error", e);
394                 status = HNAPStatus.INTERNAL_ERROR;
395             } catch (final NoSuchAlgorithmException e) {
396                 logger.debug("setAuthenticationHeaders - Internal error", e);
397                 status = HNAPStatus.INTERNAL_ERROR;
398             } catch (final SOAPException e) {
399                 // No communication happening so assume system error
400                 logger.debug("setAuthenticationHeaders - Internal error", e);
401                 status = HNAPStatus.INTERNAL_ERROR;
402             }
403         }
404     }
405
406     /**
407      * Send the SOAP message using Jetty HTTP client. Jetty is used in preference to
408      * HttpURLConnection which can result in the HNAP interface becoming unresponsive.
409      *
410      * @param action - SOAP Action to send
411      * @param timeout - Connection timeout in milliseconds
412      * @return The result
413      * @throws IOException
414      * @throws SOAPException
415      * @throws SAXException
416      * @throws ExecutionException
417      * @throws TimeoutException
418      * @throws InterruptedException
419      */
420     protected Document sendReceive(final SOAPMessage action, final int timeout) throws IOException, SOAPException,
421             SAXException, InterruptedException, TimeoutException, ExecutionException {
422         Document result;
423
424         final Request request = httpClient.POST(uri);
425         request.timeout(timeout, TimeUnit.MILLISECONDS);
426
427         final Iterator<?> it = action.getMimeHeaders().getAllHeaders();
428         while (it.hasNext()) {
429             final MimeHeader header = (MimeHeader) it.next();
430             request.header(header.getName(), header.getValue());
431         }
432
433         try (final ByteArrayOutputStream os = new ByteArrayOutputStream()) {
434             action.writeTo(os);
435             request.content(new BytesContentProvider(os.toByteArray()));
436             final ContentResponse response = request.send();
437             try (final ByteArrayInputStream is = new ByteArrayInputStream(response.getContent())) {
438                 result = parser.parse(is);
439             }
440         }
441
442         return result;
443     }
444
445     /**
446      * Output unexpected responses to the debug log.
447      *
448      * @param message
449      * @param soapResponse
450      */
451     protected void logUnexpectedResult(final String message, final Document soapResponse) {
452         // No point formatting for output if debug logging is not enabled
453         if (logger.isDebugEnabled()) {
454             try {
455                 final TransformerFactory transFactory = TransformerFactory.newInstance();
456                 final Transformer transformer = transFactory.newTransformer();
457                 final StringWriter buffer = new StringWriter();
458                 transformer.setOutputProperty(OutputKeys.OMIT_XML_DECLARATION, "yes");
459                 transformer.transform(new DOMSource(soapResponse), new StreamResult(buffer));
460                 logger.debug("{} : {}", message, buffer);
461             } catch (final TransformerException e) {
462                 logger.debug("{}", message);
463             }
464         }
465     }
466 }