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.Set;
30  import java.util.stream.Collectors;
31  import javax.annotation.Nullable;
32  import org.slf4j.Logger;
33  import org.slf4j.LoggerFactory;
34  import org.springframework.core.io.InputStreamResource;
35  import org.springframework.http.HttpHeaders;
36  import org.springframework.http.HttpStatus;
37  import org.springframework.http.ResponseEntity;
38  import org.springframework.util.LinkedMultiValueMap;
39  import org.springframework.util.MultiValueMap;
40  import org.springframework.validation.annotation.Validated;
41  import org.springframework.web.bind.annotation.ModelAttribute;
42  import org.springframework.web.bind.annotation.PathVariable;
43  import org.springframework.web.bind.annotation.RequestMapping;
44  import org.springframework.web.server.ResponseStatusException;
45  import org.springframework.web.util.UriComponentsBuilder;
46  import org.springframework.web.util.UriUtils;
47  import org.tailormap.api.annotation.AppRestController;
48  import org.tailormap.api.persistence.Application;
49  import org.tailormap.api.persistence.GeoService;
50  import org.tailormap.api.persistence.helper.GeoServiceHelper;
51  import org.tailormap.api.persistence.json.GeoServiceLayer;
52  import org.tailormap.api.persistence.json.GeoServiceProtocol;
53  import org.tailormap.api.persistence.json.ServiceAuthentication;
54  import org.tailormap.api.security.AuthorizationService;
55  
56  /**
57   * Proxy controller for OGC WMS and WMTS services. Does not attempt to hide the original service
58   * URL. Mostly useful for access to HTTP Basic secured services without sending the credentials to
59   * the client. The access control is handled by Spring Security and the authorizations configured on
60   * the service.
61   *
62   * <p>Only supports GET requests. Does not support CORS, only meant for tailormap-viewer from the
63   * same origin.
64   *
65   * <p>Implementation note: uses the Java 11 HttpClient. Spring cloud gateway can proxy with many
66   * more features but can 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/{protocol}")
72  public class GeoServiceProxyController {
73    private static final Logger logger =
74        LoggerFactory.getLogger(MethodHandles.lookup().lookupClass());
75    private final AuthorizationService authorizationService;
76  
77    public GeoServiceProxyController(AuthorizationService authorizationService) {
78      this.authorizationService = authorizationService;
79    }
80  
81    @RequestMapping(method = {GET, POST})
82    @Timed(value = "proxy", description = "Proxy OGC service calls")
83    public ResponseEntity<?> proxy(
84        @ModelAttribute Application application,
85        @ModelAttribute GeoService service,
86        @ModelAttribute GeoServiceLayer layer,
87        @PathVariable("protocol") GeoServiceProtocol protocol,
88        HttpServletRequest request) {
89  
90      if (service == null || layer == null) {
91        throw new ResponseStatusException(HttpStatus.NOT_FOUND);
92      }
93  
94      if (GeoServiceProtocol.XYZ.equals(protocol)) {
95        throw new ResponseStatusException(HttpStatus.NOT_IMPLEMENTED, "XYZ proxying not implemented");
96      }
97  
98      if (!(service.getProtocol().equals(protocol)
99          || GeoServiceProtocol.PROXIEDLEGEND.equals(protocol))) {
100       throw new ResponseStatusException(
101           HttpStatus.BAD_REQUEST, "Invalid proxy protocol: " + protocol);
102     }
103 
104     if (!service.getSettings().getUseProxy()) {
105       throw new ResponseStatusException(
106           HttpStatus.FORBIDDEN, "Proxy not enabled for requested service");
107     }
108 
109     if (authorizationService.mustDenyAccessForSecuredProxy(application, service)) {
110       logger.warn(
111           "App {} (\"{}\") is using layer \"{}\" from proxied secured service URL {} (username \"{}\"), but app is publicly accessible. Denying proxy, even if user is authenticated.",
112           application.getId(),
113           application.getName(),
114           layer.getName(),
115           service.getUrl(),
116           service.getAuthentication().getUsername());
117       throw new ResponseStatusException(HttpStatus.FORBIDDEN);
118     }
119 
120     switch (protocol) {
121       case WMS:
122       case WMTS:
123         return doProxy(buildWMSUrl(service, request), service, request);
124       case PROXIEDLEGEND:
125         URI legendURI = buildLegendURI(service, layer, request);
126         if (null == legendURI) {
127           logger.warn("No legend URL found for layer {}", layer.getName());
128           return null;
129         }
130         return doProxy(legendURI, service, request);
131       default:
132         throw new ResponseStatusException(
133             HttpStatus.BAD_REQUEST, "Unsupported proxy protocol: " + protocol);
134     }
135   }
136 
137   private @Nullable URI buildLegendURI(
138       GeoService service, GeoServiceLayer layer, HttpServletRequest request) {
139     URI legendURI = GeoServiceHelper.getLayerLegendUrlFromStyles(service, layer);
140     if (null != legendURI && null != legendURI.getQuery() && null != request.getQueryString()) {
141       // assume this is a getLegendGraphic request
142       UriComponentsBuilder uriComponentsBuilder = UriComponentsBuilder.fromUri(legendURI);
143       switch (service.getSettings().getServerType()) {
144         case GEOSERVER:
145           uriComponentsBuilder.queryParam(
146               "LEGEND_OPTIONS", "fontAntiAliasing:true;labelMargin:0;forceLabels:on");
147           break;
148         case MAPSERVER:
149         case AUTO:
150         default:
151           // no special options
152       }
153       if (null != request.getParameterMap().get("SCALE")) {
154         legendURI =
155             uriComponentsBuilder
156                 .queryParam("SCALE", request.getParameterMap().get("SCALE")[0])
157                 .build(true)
158                 .toUri();
159       }
160     }
161     return legendURI;
162   }
163 
164   private URI buildWMSUrl(GeoService service, HttpServletRequest request) {
165     final UriComponentsBuilder originalServiceUrl =
166         UriComponentsBuilder.fromHttpUrl(service.getUrl());
167     // request.getParameterMap() includes parameters from an application/x-www-form-urlencoded POST
168     // body
169     final MultiValueMap<String, String> requestParams =
170         request.getParameterMap().entrySet().stream()
171             .map(
172                 entry ->
173                     new AbstractMap.SimpleEntry<>(
174                         entry.getKey(),
175                         Arrays.stream(entry.getValue())
176                             .map(value -> UriUtils.encode(value, StandardCharsets.UTF_8))
177                             .collect(Collectors.toList())))
178             .collect(
179                 Collectors.toMap(
180                     Map.Entry::getKey, Map.Entry::getValue, (x, y) -> y, LinkedMultiValueMap::new));
181     final MultiValueMap<String, String> params =
182         buildOgcProxyRequestParams(originalServiceUrl.build(true).getQueryParams(), requestParams);
183     originalServiceUrl.replaceQueryParams(params);
184     return originalServiceUrl.build(true).toUri();
185   }
186 
187   public static MultiValueMap<String, String> buildOgcProxyRequestParams(
188       MultiValueMap<String, String> originalServiceParams,
189       MultiValueMap<String, String> requestParams) {
190     // Start with all the parameters from the request
191     final MultiValueMap<String, String> params = new LinkedMultiValueMap<>(requestParams);
192 
193     // Add original service URL parameters if they are not required OGC params (case-insensitive)
194     // and not already set by request
195     final List<String> ogcParams = List.of(new String[] {"SERVICE", "REQUEST", "VERSION"});
196     for (Map.Entry<String, List<String>> serviceParam : originalServiceParams.entrySet()) {
197       if (!params.containsKey(serviceParam.getKey())
198           && !ogcParams.contains(serviceParam.getKey().toUpperCase(Locale.ROOT))) {
199         params.put(serviceParam.getKey(), serviceParam.getValue());
200       }
201     }
202     return params;
203   }
204 
205   private static ResponseEntity<?> doProxy(
206       URI uri, GeoService service, HttpServletRequest request) {
207     final HttpClient.Builder builder =
208         HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL);
209     final HttpClient httpClient = builder.build();
210 
211     HttpRequest.Builder requestBuilder = HttpRequest.newBuilder().uri(uri);
212 
213     addForwardedForRequestHeaders(requestBuilder, request);
214 
215     passthroughRequestHeaders(
216         requestBuilder,
217         request,
218         Set.of(
219             "Accept",
220             "If-Modified-Since",
221             "If-Unmodified-Since",
222             "If-Match",
223             "If-None-Match",
224             "If-Range",
225             "Range",
226             "Referer",
227             "User-Agent"));
228 
229     if (service.getAuthentication() != null
230         && service.getAuthentication().getMethod() == ServiceAuthentication.MethodEnum.PASSWORD) {
231       setHttpBasicAuthenticationHeader(
232           requestBuilder,
233           service.getAuthentication().getUsername(),
234           service.getAuthentication().getPassword());
235     }
236 
237     try {
238       // TODO: close JPA connection before proxying
239       HttpResponse<InputStream> response =
240           httpClient.send(requestBuilder.build(), HttpResponse.BodyHandlers.ofInputStream());
241 
242       // If the server does not accept our credentials it might 'hide' the layer or even send a 401
243       // Unauthorized status. We do not send the WWW-Authenticate header back, so the client will
244       // get the error but not an authorization popup.
245       // It would be nice if proxy (auth) errors were logged and available in the admin interface.
246       // Currently, a layer will just stop working if the geo service credentials are changed
247       // without updating them in the geo service registry.
248       InputStreamResource body = new InputStreamResource(response.body());
249       HttpHeaders headers =
250           passthroughResponseHeaders(
251               response.headers(),
252               Set.of(
253                   "Content-Type",
254                   "Content-Length",
255                   "Content-Range",
256                   "Content-Disposition",
257                   "Cache-Control",
258                   "Expires",
259                   "Last-Modified",
260                   "ETag",
261                   "Pragma"));
262       return ResponseEntity.status(response.statusCode()).headers(headers).body(body);
263     } catch (Exception e) {
264       return ResponseEntity.status(HttpStatus.BAD_GATEWAY).body("Bad Gateway");
265     }
266   }
267 }