2 * Copyright (c) 2010-2023 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 String realProtocol = request.getHeader(X_FORWARDED_PROTO);
146 if (realProtocol == null || realProtocol.isBlank()) {
147 realProtocol = request.getScheme();
149 return requestURL.replace(0, requestURL.indexOf(":"), realProtocol).toString();
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.
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
163 * the querystring part of the received request, may be {@code null}
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, "");
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);
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)) {
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()));
197 * Returns an html formatted text representing all the available Smarther Bridge applications.
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
204 * @return a string containing the html formatted text
206 private String formatApplications(String applicationTemplate, String servletBaseURL) {
207 final Set<SmartherAccountHandler> applications = accountService.getSmartherAccountHandlers();
209 return applications.isEmpty() ? HTML_EMPTY_APPLICATIONS
210 : applications.stream().map(p -> formatApplication(applicationTemplate, p, servletBaseURL))
211 .collect(Collectors.joining());
215 * Returns an html formatted text representing a given Smarther Bridge application.
217 * @param applicationTemplate
218 * the html template to format the application with
220 * the Smarther application handler to use
221 * @param servletBaseURL
222 * the redirect_uri to link to the authorization button as authorization url
224 * @return a string containing the html formatted text
226 private String formatApplication(String applicationTemplate, SmartherAccountHandler handler,
227 String servletBaseURL) {
228 final Map<String, String> map = new HashMap<>();
230 map.put(APPLICATION_ID, handler.getUID().getAsString());
231 map.put(APPLICATION_NAME, handler.getLabel());
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));
238 map.put(APPLICATION_AUTHORIZED_CLASS, "");
239 map.put(APPLICATION_LOCATIONS, "");
242 map.put(APPLICATION_AUTHORIZE, handler.formatAuthorizationUrl(servletBaseURL));
243 return replaceKeysFromMap(applicationTemplate, map);
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.
251 * the template to replace keys on
253 * the map containing the key/value pairs to replace in the template
255 * @return a string containing the resulting template after the replace process
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();
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);
270 return sb.toString();