2 * Copyright (c) 2010-2020 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
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
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.bticinosmarther.internal.account;
15 import java.io.IOException;
16 import java.nio.charset.StandardCharsets;
17 import java.util.HashMap;
20 import java.util.regex.Matcher;
21 import java.util.regex.Pattern;
22 import java.util.stream.Collectors;
24 import javax.servlet.ServletException;
25 import javax.servlet.http.HttpServlet;
26 import javax.servlet.http.HttpServletRequest;
27 import javax.servlet.http.HttpServletResponse;
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;
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.
45 * @author Fabio Possieri - Initial contribution
48 public class SmartherAuthorizationServlet extends HttpServlet {
50 private static final long serialVersionUID = 5199173744807168342L;
52 private static final String CONTENT_TYPE = "text/html;charset=UTF-8";
53 private static final String X_FORWARDED_PROTO = "X-Forwarded-Proto";
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";
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>";
65 private static final Pattern MESSAGE_KEY_PATTERN = Pattern.compile("\\$\\{([^\\}]+)\\}");
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";
81 private final Logger logger = LoggerFactory.getLogger(SmartherAuthorizationServlet.class);
83 private final SmartherAccountService accountService;
84 private final String indexTemplate;
85 private final String applicationTemplate;
88 * Constructs a {@code SmartherAuthorizationServlet} associated to the given {@link SmartherAccountService} service
89 * and with the given html index/application templates.
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
98 public SmartherAuthorizationServlet(SmartherAccountService accountService, String indexTemplate,
99 String applicationTemplate) {
100 this.accountService = accountService;
101 this.indexTemplate = indexTemplate;
102 this.applicationTemplate = applicationTemplate;
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);
112 // Handle the received data
113 final Map<String, String> replaceMap = new HashMap<>();
114 handleSmartherRedirect(replaceMap, servletBaseURL, request.getQueryString());
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();
129 throw new ServletException("Authorization callback with null request/response");
134 * Extracts the servlet base url from the received http request, handling eventual reverse proxy.
137 * the received http request
139 * @return a string containing the servlet base url
141 private String extractServletBaseURL(HttpServletRequest request) {
142 final StringBuffer requestURL = request.getRequestURL();
144 // Try to infer the real protocol from request headers
145 final String realProtocol = StringUtil.defaultIfBlank(request.getHeader(X_FORWARDED_PROTO),
146 request.getScheme());
148 return requestURL.replace(0, requestURL.indexOf(":"), realProtocol).toString();
152 * Handles a call from BTicino/Legrand API gateway to the redirect_uri, dispatching the authorization flow to the
153 * proper authorization handler.
154 * If the user was authorized, this is passed on to the handler; in case of an error, this is shown to the user.
155 * Based on all these different outcomes the html response is generated to inform the user.
158 * a map with key string values to use in the html templates
159 * @param servletBaseURL
160 * the servlet base url to compose the correct API gateway redirect_uri
162 * the querystring part of the received request, may be {@code null}
164 private void handleSmartherRedirect(Map<String, String> replaceMap, String servletBaseURL,
165 @Nullable String queryString) {
166 replaceMap.put(KEY_AUTHORIZED_BRIDGE, "");
167 replaceMap.put(KEY_ERROR, "");
168 replaceMap.put(KEY_PAGE_REFRESH, "");
170 if (queryString != null) {
171 final MultiMap<String> params = new MultiMap<>();
172 UrlEncoded.decodeTo(queryString, params, StandardCharsets.UTF_8.name());
173 final String reqCode = params.getString(PARAM_CODE);
174 final String reqState = params.getString(PARAM_STATE);
175 final String reqError = params.getString(PARAM_ERROR);
177 replaceMap.put(KEY_PAGE_REFRESH,
178 params.isEmpty() ? "" : String.format(HTML_META_REFRESH_CONTENT, servletBaseURL));
179 if (!StringUtil.isBlank(reqError)) {
180 logger.debug("Authorization redirected with an error: {}", reqError);
181 replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, reqError));
182 } else if (!StringUtil.isBlank(reqState)) {
184 logger.trace("Received from authorization - state:[{}] code:[{}]", reqState, reqCode);
185 replaceMap.put(KEY_AUTHORIZED_BRIDGE, String.format(HTML_BRIDGE_AUTHORIZED,
186 accountService.dispatchAuthorization(servletBaseURL, reqState, reqCode)));
187 } catch (SmartherGatewayException e) {
188 logger.debug("Exception during authorizaton: ", e);
189 replaceMap.put(KEY_ERROR, String.format(HTML_ERROR, e.getMessage()));
196 * Returns an html formatted text representing all the available Smarther Bridge applications.
198 * @param applicationTemplate
199 * the html template to format the application with
200 * @param servletBaseURL
201 * the redirect_uri to link to the authorization button as authorization url
203 * @return a string containing the html formatted text
205 private String formatApplications(String applicationTemplate, String servletBaseURL) {
206 final Set<SmartherAccountHandler> applications = accountService.getSmartherAccountHandlers();
208 return applications.isEmpty() ? HTML_EMPTY_APPLICATIONS
209 : applications.stream().map(p -> formatApplication(applicationTemplate, p, servletBaseURL))
210 .collect(Collectors.joining());
214 * Returns an html formatted text representing a given Smarther Bridge application.
216 * @param applicationTemplate
217 * the html template to format the application with
219 * the Smarther application handler to use
220 * @param servletBaseURL
221 * the redirect_uri to link to the authorization button as authorization url
223 * @return a string containing the html formatted text
225 private String formatApplication(String applicationTemplate, SmartherAccountHandler handler,
226 String servletBaseURL) {
227 final Map<String, String> map = new HashMap<>();
229 map.put(APPLICATION_ID, handler.getUID().getAsString());
230 map.put(APPLICATION_NAME, handler.getLabel());
232 if (handler.isAuthorized()) {
233 final String availableLocations = Location.toNameString(handler.getLocations());
234 map.put(APPLICATION_AUTHORIZED_CLASS, " authorized");
235 map.put(APPLICATION_LOCATIONS, String.format(" (Available locations: %s)", availableLocations));
237 map.put(APPLICATION_AUTHORIZED_CLASS, "");
238 map.put(APPLICATION_LOCATIONS, "");
241 map.put(APPLICATION_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
242 return replaceKeysFromMap(applicationTemplate, map);
246 * Replaces all keys found in the template with the values matched from the map.
247 * If a key is not found in the map, it is kept unchanged in the template.
250 * the template to replace keys on
252 * the map containing the key/value pairs to replace in the template
254 * @return a string containing the resulting template after the replace process
256 private String replaceKeysFromMap(String template, Map<String, String> map) {
257 final Matcher m = MESSAGE_KEY_PATTERN.matcher(template);
258 final StringBuffer sb = new StringBuffer();
262 final String key = m.group(1);
263 m.appendReplacement(sb, Matcher.quoteReplacement(map.getOrDefault(key, "${" + key + '}')));
264 } catch (RuntimeException e) {
265 logger.warn("Error occurred during template filling, cause ", e);
269 return sb.toString();