View Javadoc
1   /*
2    * Copyright (C) 2022 B3Partners B.V.
3    *
4    * SPDX-License-Identifier: MIT
5    */
6   package org.tailormap.api.controller;
7   
8   import static org.springframework.web.bind.annotation.RequestMethod.GET;
9   import static org.springframework.web.bind.annotation.RequestMethod.POST;
10  import static org.tailormap.api.util.HttpProxyUtil.addForwardedForRequestHeaders;
11  import static org.tailormap.api.util.HttpProxyUtil.passthroughRequestHeaders;
12  import static org.tailormap.api.util.HttpProxyUtil.passthroughResponseHeaders;
13  import static org.tailormap.api.util.HttpProxyUtil.setHttpBasicAuthenticationHeader;
14  
15  import io.micrometer.core.annotation.Timed;
16  import jakarta.servlet.http.HttpServletRequest;
17  import java.io.InputStream;
18  import java.lang.invoke.MethodHandles;
19  import java.net.URI;
20  import java.net.http.HttpClient;
21  import java.net.http.HttpRequest;
22  import java.net.http.HttpResponse;
23  import java.nio.charset.StandardCharsets;
24  import java.util.AbstractMap;
25  import java.util.Arrays;
26  import java.util.List;
27  import java.util.Locale;
28  import java.util.Map;
29  import java.util.Objects;
30  import java.util.Set;
31  import java.util.stream.Collectors;
32  import javax.annotation.Nullable;
33  import org.slf4j.Logger;
34  import org.slf4j.LoggerFactory;
35  import org.springframework.core.io.InputStreamResource;
36  import org.springframework.http.HttpHeaders;
37  import org.springframework.http.HttpStatus;
38  import org.springframework.http.ResponseEntity;
39  import org.springframework.util.LinkedMultiValueMap;
40  import org.springframework.util.MultiValueMap;
41  import org.springframework.validation.annotation.Validated;
42  import org.springframework.web.bind.annotation.ModelAttribute;
43  import org.springframework.web.bind.annotation.PathVariable;
44  import org.springframework.web.bind.annotation.RequestMapping;
45  import org.springframework.web.server.ResponseStatusException;
46  import org.springframework.web.util.UriComponentsBuilder;
47  import org.springframework.web.util.UriUtils;
48  import org.tailormap.api.annotation.AppRestController;
49  import org.tailormap.api.persistence.Application;
50  import org.tailormap.api.persistence.GeoService;
51  import org.tailormap.api.persistence.helper.GeoServiceHelper;
52  import org.tailormap.api.persistence.json.GeoServiceLayer;
53  import org.tailormap.api.persistence.json.GeoServiceProtocol;
54  import org.tailormap.api.persistence.json.ServiceAuthentication;
55  import org.tailormap.api.security.AuthorizationService;
56  
57  /**
58   * Proxy controller for OGC WMS, WMTS, and 3D Tiles services. Does not attempt to hide the original service URL. Mostly
59   * useful for access to HTTP Basic secured services without sending the credentials to the client. The access control is
60   * handled by Spring Security and the authorizations configured on the service.
61   *
62   * <p>Only supports GET requests. Does not support CORS, only meant for tailormap-viewer from the same origin.
63   *
64   * <p>Implementation note: uses the Java 11 HttpClient. Spring cloud gateway can proxy with many more features but can
65   * not be used in a non-reactive application.
66   */
67  @AppRestController
68  @Validated
69  // Can't use ${tailormap-api.base-path} because linkTo() won't work
70  @RequestMapping(path = "/api/{viewerKind}/{viewerName}/layer/{appLayerId}/proxy")
71  public class GeoServiceProxyController {
72    private static final Logger logger =
73        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
74    private final AuthorizationService authorizationService;
75    public static final String TILES3D_DESCRIPTION_PATH = "tiles3dDescription";
76  
77    public GeoServiceProxyController(AuthorizationService authorizationService) {
78      this.authorizationService = authorizationService;
79    }
80  
81    @RequestMapping(
82        method = {GET, POST},
83        path = "/tiles3d/**")
84    public ResponseEntity<?> proxy3dtiles(
85        @ModelAttribute Application application,
86        @ModelAttribute GeoService service,
87        @ModelAttribute GeoServiceLayer layer,
88        HttpServletRequest request) {
89  
90      checkRequestValidity(application, service, layer, GeoServiceProtocol.TILES3D);
91  
92      return doProxy(build3DTilesUrl(service, request), service, request);
93    }
94  
95    @RequestMapping(
96        method = {GET, POST},
97        path = "/{protocol}")
98    @Timed(value = "proxy", description = "Proxy OGC service calls")
99    public ResponseEntity<?> proxy(
100       @ModelAttribute Application application,
101       @ModelAttribute GeoService service,
102       @ModelAttribute GeoServiceLayer layer,
103       @PathVariable("protocol") GeoServiceProtocol protocol,
104       HttpServletRequest request) {
105 
106     checkRequestValidity(application, service, layer, protocol);
107 
108     switch (protocol) {
109       case WMS:
110       case WMTS:
111         return doProxy(buildWMSUrl(service, request), service, request);
112       case PROXIEDLEGEND:
113         URI legendURI = buildLegendURI(service, layer, request);
114         if (null == legendURI) {
115           logger.warn("No legend URL found for layer {}", layer.getName());
116           return null;
117         }
118         return doProxy(legendURI, service, request);
119       case TILES3D:
120         throw new ResponseStatusException(
121             HttpStatus.BAD_REQUEST, "Incorrect 3D Tiles proxy request: No path to capabilities or content");
122       default:
123         throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
124     }
125   }
126 
127   private void checkRequestValidity(
128       Application application, GeoService service, GeoServiceLayer layer, GeoServiceProtocol protocol) {
129     if (service == null || layer == null) {
130       throw new ResponseStatusException(HttpStatus.NOT_FOUND);
131     }
132 
133     if (GeoServiceProtocol.XYZ.equals(protocol)) {
134       throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
135     }
136 
137     if (!(service.getProtocol().equals(protocol) || GeoServiceProtocol.PROXIEDLEGEND.equals(protocol))) {
138       throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
139     }
140 
141     if (!service.getSettings().getUseProxy()) {
142       throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
143     }
144 
145     if (authorizationService.mustDenyAccessForSecuredProxy(application, service)) {
146       logger.warn(
147           "App {} (\"{}\") is using layer \"{}\" from proxied secured service URL {} (username \"{}\"), but app is publicly accessible. Denying proxy, even if user is authenticated.",
148           application.getId(),
149           application.getName(),
150           layer.getName(),
151           service.getUrl(),
152           service.getAuthentication().getUsername());
153       throw new ResponseStatusException(HttpStatus.FORBIDDEN);
154     }
155   }
156 
157   private @Nullable URI buildLegendURI(GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
158     URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
159     if (null != legendURI && null != legendURI.getQuery() && null != request.getQueryString()) {
160       // assume this is a getLegendGraphic request
161       UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
162       switch (service.getSettings().getServerType()) {
163         case GEOSERVER:
164           uriComponentsBuilder.queryParam(
165               "LEGEND_OPTIONS", "fontAntiAliasing:true;labelMargin:0;forceLabels:on");
166           break;
167         case MAPSERVER:
168         case AUTO:
169         default:
170           // no special options
171       }
172       if (null != request.getParameterMap().get("SCALE")) {
173         legendURI = uriComponentsBuilder
174             .queryParam("SCALE", request.getParameterMap().get("SCALE")[0])
175             .build(true)
176             .toUri();
177       }
178     }
179     return legendURI;
180   }
181 
182   private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
183     final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
184     // request.getParameterMap() includes parameters from an application/x-www-form-urlencoded POST
185     // body
186     final MultiValueMap<String, String> requestParams = request.getParameterMap().entrySet().stream()
187         .map(entry -> new AbstractMap.SimpleEntry<>(
188             entry.getKey(),
189             Arrays.stream(entry.getValue())
190                 .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
191                 .collect(Collectors.toList())))
192         .collect(Collectors.toMap(
193             Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
194     final MultiValueMap<String, String> params =
195         buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
196     originalServiceUrl.replaceQueryParams(params);
197     return originalServiceUrl.build(true).toUri();
198   }
199 
200   public static MultiValueMap<String, String> buildOgcProxyRequestParams(
201       MultiValueMap<String, String> originalServiceParams, MultiValueMap<String, String> requestParams) {
202     // Start with all the parameters from the request
203     final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
204 
205     // Add original service URL parameters if they are not required OGC params (case-insensitive)
206     // and not already set by request
207     final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
208     for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
209       if (!params.containsKey(serviceParam.getKey())
210           && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
211         params.put(serviceParam.getKey(), serviceParam.getValue());
212       }
213     }
214     return params;
215   }
216 
217   private URI build3DTilesUrl(GeoService service, HttpServletRequest request) {
218     // The URL in the GeoService refers to the location of the JSON file describing the tileset,
219     // e.g. example.com/buildings/3dtiles. The paths to the subtrees and tiles of the tilesets do not include the
220     // '/3dtiles' (or '/tileset.json') part of the path. Their paths are e.g.
221     // example.com/buildings/subtrees/... or example.com/buildings/t/...
222     final UriComponentsBuilder originalServiceUrl = UriComponentsBuilder.fromUriString(service.getUrl());
223     String baseUrl = originalServiceUrl.build(true).toUriString();
224     String pathToContent = request.getRequestURI().split("/proxy/tiles3d/", 2)[1];
225 
226     // Return service URL when the request is for the JSON file describing the tileset
227     if (Objects.equals(pathToContent, TILES3D_DESCRIPTION_PATH)) {
228       return UriComponentsBuilder.fromUriString(baseUrl).build(true).toUri();
229     }
230 
231     // Remove the part of the service URL referring to the JSON file describing the tileset
232     int lastSlashIndex = baseUrl.lastIndexOf('/');
233     if (lastSlashIndex != -1) {
234       baseUrl = baseUrl.substring(0, lastSlashIndex + 1);
235     }
236 
237     // Return final URL with specific path to the tile or subtree
238     String finalUrl = baseUrl + pathToContent;
239     return UriComponentsBuilder.fromUriString(finalUrl).build(true).toUri();
240   }
241 
242   private static ResponseEntity<?> doProxy(URI uri, GeoService service, HttpServletRequest request) {
243     final HttpClient.Builder builder = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
244     final HttpClient httpClient = builder.build();
245 
246     HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(uri);
247 
248     addForwardedForRequestHeaders(requestBuilder, request);
249 
250     passthroughRequestHeaders(
251         requestBuilder,
252         request,
253         Set.of(
254             "Accept",
255             "If-Modified-Since",
256             "If-Unmodified-Since",
257             "If-Match",
258             "If-None-Match",
259             "If-Range",
260             "Range",
261             "Referer",
262             "User-Agent"));
263 
264     if (service.getAuthentication() != null
265         && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
266       setHttpBasicAuthenticationHeader(
267           requestBuilder,
268           service.getAuthentication().getUsername(),
269           service.getAuthentication().getPassword());
270     }
271 
272     try {
273       // TODO: close JPA connection before proxying
274       HttpResponse<InputStream> response =
275           httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
276 
277       // If the server does not accept our credentials it might 'hide' the layer or even send a 401
278       // Unauthorized status. We do not send the WWW-Authenticate header back, so the client will
279       // get the error but not an authorization popup.
280       // It would be nice if proxy (auth) errors were logged and available in the admin interface.
281       // Currently, a layer will just stop working if the geo service credentials are changed
282       // without updating them in the geo service registry.
283       InputStreamResource body = new InputStreamResource(response.body());
284       HttpHeaders headers = passthroughResponseHeaders(
285           response.headers(),
286           Set.of(
287               "Content-Type",
288               "Content-Length",
289               "Content-Range",
290               "Content-Disposition",
291               "Cache-Control",
292               "Expires",
293               "Last-Modified",
294               "ETag",
295               "Pragma"));
296       return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
297     } catch (Exception e) {
298       return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
299     }
300   }
301 }