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