]> git.basschouten.com Git - openhab-addons.git/blob
25665ad46a61f2f3556f1c0c8c2be26c7f6d1362
[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.bticinosmarther.internal.account;
14
15 import java.io.IOException;
16 import java.nio.charset.StandardCharsets;
17 import java.util.HashMap;
18 import java.util.Map;
19 import java.util.Set;
20 import java.util.regex.Matcher;
21 import java.util.regex.Pattern;
22 import java.util.stream.Collectors;
23
24 import javax.servlet.ServletException;
25 import javax.servlet.http.HttpServlet;
26 import javax.servlet.http.HttpServletRequest;
27 import javax.servlet.http.HttpServletResponse;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.http.HttpStatus;
32 import org.eclipse.jetty.util.MultiMap;
33 import org.eclipse.jetty.util.UrlEncoded;
34 import org.openhab.binding.bticinosmarther.internal.api.dto.Location;
35 import org.openhab.binding.bticinosmarther.internal.api.exception.SmartherGatewayException;
36 import org.openhab.binding.bticinosmarther.internal.util.StringUtil;
37 import org.slf4j.Logger;
38 import org.slf4j.LoggerFactory;
39
40 /**
41  * The {@code SmartherAuthorizationServlet} class acts as the registered endpoint for the user to automatically manage
42  * the BTicino/Legrand API authorization process.
43  * The servlet follows the OAuth2 Authorization Code flow, saving the resulting refreshToken within the Smarther Bridge.
44  *
45  * @author Fabio Possieri - Initial contribution
46  */
47 @NonNullByDefault
48 public class SmartherAuthorizationServlet extends HttpServlet {
49
50     private static final long serialVersionUID = 5199173744807168342L;
51
52     private static final String CONTENT_TYPE = "text/html;charset=UTF-8";
53     private static final String X_FORWARDED_PROTO = "X-Forwarded-Proto";
54
55     // Http request parameters
56     private static final String PARAM_CODE = "code";
57     private static final String PARAM_STATE = "state";
58     private static final String PARAM_ERROR = "error";
59
60     // Simple HTML templates for inserting messages.
61     private static final String HTML_EMPTY_APPLICATIONS = "<p class='block'>Manually add a Smarther Bridge to authorize it here<p>";
62     private static final String HTML_BRIDGE_AUTHORIZED = "<p class='block authorized'>Bridge authorized for Client Id %s</p>";
63     private static final String HTML_ERROR = "<p class='block error'>Call to Smarther API gateway failed with error: %s</p>";
64
65     private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
66
67     // Keys present in the index.html
68     private static final String KEY_PAGE_REFRESH = "pageRefresh";
69     private static final String HTML_META_REFRESH_CONTENT = "<meta http-equiv='refresh' content='10; url=%s'>";
70     private static final String KEY_AUTHORIZED_BRIDGE = "authorizedBridge";
71     private static final String KEY_ERROR = "error";
72     private static final String KEY_APPLICATIONS = "applications";
73     private static final String KEY_REDIRECT_URI = "redirectUri";
74     // Keys present in the application.html
75     private static final String APPLICATION_ID = "application.id";
76     private static final String APPLICATION_NAME = "application.name";
77     private static final String APPLICATION_LOCATIONS = "application.locations";
78     private static final String APPLICATION_AUTHORIZED_CLASS = "application.authorized";
79     private static final String APPLICATION_AUTHORIZE = "application.authorize";
80
81     private final Logger logger = LoggerFactory.getLogger(SmartherAuthorizationServlet.class);
82
83     private final SmartherAccountService accountService;
84     private final String indexTemplate;
85     private final String applicationTemplate;
86
87     /**
88      * Constructs a {@code SmartherAuthorizationServlet} associated to the given {@link SmartherAccountService} service
89      * and with the given html index/application templates.
90      *
91      * @param accountService
92      *            the account service to associate to the servlet
93      * @param indexTemplate
94      *            the html template to use as index page for the user
95      * @param applicationTemplate
96      *            the html template to use as application page for the user
97      */
98     public SmartherAuthorizationServlet(SmartherAccountService accountService, String indexTemplate,
99             String applicationTemplate) {
100         this.accountService = accountService;
101         this.indexTemplate = indexTemplate;
102         this.applicationTemplate = applicationTemplate;
103     }
104
105     @Override
106     protected void doGet(@Nullable HttpServletRequest request, @Nullable HttpServletResponse response)
107             throws ServletException, IOException {
108         if (request != null && response != null) {
109             final String servletBaseURL = extractServletBaseURL(request);
110             logger.debug("Authorization callback servlet received GET request {}", servletBaseURL);
111
112             // Handle the received data
113             final Map<String, String> replaceMap = new HashMap<>();
114             handleSmartherRedirect(replaceMap, servletBaseURL, request.getQueryString());
115
116             // Build a http 200 (Success) response for the caller
117             response.setContentType(CONTENT_TYPE);
118             response.setStatus(HttpStatus.OK_200);
119             replaceMap.put(KEY_REDIRECT_URI, servletBaseURL);
120             replaceMap.put(KEY_APPLICATIONS, formatApplications(applicationTemplate, servletBaseURL));
121             response.getWriter().append(replaceKeysFromMap(indexTemplate, replaceMap));
122             response.getWriter().close();
123         } else if (response != null) {
124             // Build a http 400 (Bad Request) error response for the caller
125             response.setContentType(CONTENT_TYPE);
126             response.setStatus(HttpStatus.BAD_REQUEST_400);
127             response.getWriter().close();
128         } else {
129             throw new ServletException("Authorization callback with null request/response");
130         }
131     }
132
133     /**
134      * Extracts the servlet base url from the received http request, handling eventual reverse proxy.
135      *
136      * @param request
137      *            the received http request
138      *
139      * @return a string containing the servlet base url
140      */
141     private String extractServletBaseURL(HttpServletRequest request) {
142         final StringBuffer requestURL = request.getRequestURL();
143
144         // Try to infer the real protocol from request headers
145         String realProtocol = request.getHeader(X_FORWARDED_PROTO);
146         if (realProtocol == null || realProtocol.isBlank()) {
147             realProtocol = request.getScheme();
148         }
149         return requestURL.replace(0, requestURL.indexOf(":"), realProtocol).toString();
150     }
151
152     /**
153      * Handles a call from BTicino/Legrand API gateway to the redirect_uri, dispatching the authorization flow to the
154      * proper authorization handler.
155      * If the user was authorized, this is passed on to the handler; in case of an error, this is shown to the user.
156      * Based on all these different outcomes the html response is generated to inform the user.
157      *
158      * @param replaceMap
159      *            a map with key string values to use in the html templates
160      * @param servletBaseURL
161      *            the servlet base url to compose the correct API gateway redirect_uri
162      * @param queryString
163      *            the querystring part of the received request, may be {@code null}
164      */
165     private void handleSmartherRedirect(Map<String, String> replaceMap, String servletBaseURL,
166             @Nullable String queryString) {
167         replaceMap.put(KEY_AUTHORIZED_BRIDGE, "");
168         replaceMap.put(KEY_ERROR, "");
169         replaceMap.put(KEY_PAGE_REFRESH, "");
170
171         if (queryString != null) {
172             final MultiMap<String> params = new MultiMap<>();
173             UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name());
174             final String reqCode = params.getString(PARAM_CODE);
175             final String reqState = params.getString(PARAM_STATE);
176             final String reqError = params.getString(PARAM_ERROR);
177
178             replaceMap.put(KEY_PAGE_REFRESH,
179                     params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL));
180             if (!StringUtil.isBlank(reqError)) {
181                 logger.debug("Authorization redirected with an error: {}", reqError);
182                 replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError));
183             } else if (!StringUtil.isBlank(reqState)) {
184                 try {
185                     logger.trace("Received from authorization - state:[{}] code:[{}]", reqState, reqCode);
186                     replaceMap.put(KEY_AUTHORIZED_BRIDGE, String.format(HTML_BRIDGE_AUTHORIZED,
187                             accountService.dispatchAuthorization(servletBaseURL, reqState, reqCode)));
188                 } catch (SmartherGatewayException e) {
189                     logger.debug("Exception during authorizaton: ", e);
190                     replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage()));
191                 }
192             }
193         }
194     }
195
196     /**
197      * Returns an html formatted text representing all the available Smarther Bridge applications.
198      *
199      * @param applicationTemplate
200      *            the html template to format the application with
201      * @param servletBaseURL
202      *            the redirect_uri to link to the authorization button as authorization url
203      *
204      * @return a string containing the html formatted text
205      */
206     private String formatApplications(String applicationTemplate, String servletBaseURL) {
207         final Set<SmartherAccountHandler> applications = accountService.getSmartherAccountHandlers();
208
209         return applications.isEmpty() ? HTML_EMPTY_APPLICATIONS
210                 : applications.stream().map(p -> formatApplication(applicationTemplate, p, servletBaseURL))
211                         .collect(Collectors.joining());
212     }
213
214     /**
215      * Returns an html formatted text representing a given Smarther Bridge application.
216      *
217      * @param applicationTemplate
218      *            the html template to format the application with
219      * @param handler
220      *            the Smarther application handler to use
221      * @param servletBaseURL
222      *            the redirect_uri to link to the authorization button as authorization url
223      *
224      * @return a string containing the html formatted text
225      */
226     private String formatApplication(String applicationTemplate, SmartherAccountHandler handler,
227             String servletBaseURL) {
228         final Map<String, String> map = new HashMap<>();
229
230         map.put(APPLICATION_ID, handler.getUID().getAsString());
231         map.put(APPLICATION_NAME, handler.getLabel());
232
233         if (handler.isAuthorized()) {
234             final String availableLocations = Location.toNameString(handler.getLocations());
235             map.put(APPLICATION_AUTHORIZED_CLASS, " authorized");
236             map.put(APPLICATION_LOCATIONS, String.format(" (Available locations: %s)", availableLocations));
237         } else {
238             map.put(APPLICATION_AUTHORIZED_CLASS, "");
239             map.put(APPLICATION_LOCATIONS, "");
240         }
241
242         map.put(APPLICATION_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
243         return replaceKeysFromMap(applicationTemplate, map);
244     }
245
246     /**
247      * Replaces all keys found in the template with the values matched from the map.
248      * If a key is not found in the map, it is kept unchanged in the template.
249      *
250      * @param template
251      *            the template to replace keys on
252      * @param map
253      *            the map containing the key/value pairs to replace in the template
254      *
255      * @return a string containing the resulting template after the replace process
256      */
257     private String replaceKeysFromMap(String template, Map<String, String> map) {
258         final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
259         final StringBuffer sb = new StringBuffer();
260
261         while (m.find()) {
262             try {
263                 final String key = m.group(1);
264                 m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
265             } catch (RuntimeException e) {
266                 logger.warn("Error occurred during template filling, cause ", e);
267             }
268         }
269         m.appendTail(sb);
270         return sb.toString();
271     }
272 }