View Javadoc
1   /*
2    * This file is part of ***  M y C o R e  ***
3    * See http://www.mycore.de/ for details.
4    *
5    * MyCoRe is free software: you can redistribute it and/or modify
6    * it under the terms of the GNU General Public License as published by
7    * the Free Software Foundation, either version 3 of the License, or
8    * (at your option) any later version.
9    *
10   * MyCoRe is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with MyCoRe.  If not, see <http://www.gnu.org/licenses/>.
17   */
18  
19  package org.mycore.frontend;
20  
21  import java.io.File;
22  import java.net.InetAddress;
23  import java.net.MalformedURLException;
24  import java.net.URI;
25  import java.net.URL;
26  import java.net.UnknownHostException;
27  import java.util.Date;
28  import java.util.Enumeration;
29  import java.util.Map;
30  import java.util.Objects;
31  import java.util.Optional;
32  import java.util.Set;
33  import java.util.StringTokenizer;
34  import java.util.TreeSet;
35  import java.util.function.Supplier;
36  import java.util.stream.Collectors;
37  import java.util.stream.Stream;
38  
39  import org.apache.logging.log4j.LogManager;
40  import org.apache.logging.log4j.Logger;
41  import org.mycore.common.MCRException;
42  import org.mycore.common.MCRSession;
43  import org.mycore.common.MCRSessionMgr;
44  import org.mycore.common.config.MCRConfiguration2;
45  import org.mycore.common.config.MCRConfigurationException;
46  import org.mycore.frontend.servlets.MCRServletJob;
47  import org.mycore.services.i18n.MCRTranslation;
48  
49  import jakarta.servlet.ServletContext;
50  import jakarta.servlet.ServletRequest;
51  import jakarta.servlet.http.HttpServletRequest;
52  import jakarta.servlet.http.HttpServletResponse;
53  
54  /**
55   * Servlet/Jersey Resource utility class.
56   */
57  public class MCRFrontendUtil {
58  
59      private static final String PROXY_HEADER_HOST = "X-Forwarded-Host";
60  
61      private static final String PROXY_HEADER_SCHEME = "X-Forwarded-Proto";
62  
63      private static final String PROXY_HEADER_PORT = "X-Forwarded-Port";
64  
65      private static final String PROXY_HEADER_PATH = "X-Forwarded-Path";
66  
67      private static final String PROXY_HEADER_REMOTE_IP = "X-Forwarded-For";
68  
69      public static final String BASE_URL_ATTRIBUTE = "org.mycore.base.url";
70  
71      public static final String SESSION_NETMASK_IPV4_STRING = MCRConfiguration2
72          .getString("MCR.Servlet.Session.NetMask.IPv4").orElse("255.255.255.255");
73  
74      public static final String SESSION_NETMASK_IPV6_STRING = MCRConfiguration2
75          .getString("MCR.Servlet.Session.NetMask.IPv6").orElse("FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF");
76  
77      private static String BASE_URL;
78  
79      private static String BASE_HOST_IP;
80  
81      private static Logger LOGGER = LogManager.getLogger();
82  
83      public static byte[] SESSION_NETMASK_IPV4;
84  
85      public static byte[] SESSION_NETMASK_IPV6;
86  
87      private static final ThreadLocal<Map.Entry<String, MCRServletJob>> CURRENT_SERVLET_JOB = new ThreadLocal<>();
88  
89      static {
90          try {
91              SESSION_NETMASK_IPV4 = InetAddress.getByName(MCRFrontendUtil.SESSION_NETMASK_IPV4_STRING).getAddress();
92          } catch (UnknownHostException e) {
93              throw new MCRConfigurationException("MCR.Servlet.Session.NetMask.IPv4 is not a correct IPv4 network mask.",
94                  e);
95          }
96          try {
97              SESSION_NETMASK_IPV6 = InetAddress.getByName(MCRFrontendUtil.SESSION_NETMASK_IPV6_STRING).getAddress();
98          } catch (UnknownHostException e) {
99              throw new MCRConfigurationException("MCR.Servlet.Session.NetMask.IPv6 is not a correct IPv6 network mask.",
100                 e);
101         }
102         prepareBaseURLs(""); // getBaseURL() etc. may be called before any HTTP Request
103         addSessionListener();
104     }
105 
106     /** The IP addresses of trusted web proxies */
107     protected static final Set<String> TRUSTED_PROXIES = getTrustedProxies();
108 
109     /** returns the base URL of the mycore system */
110     public static String getBaseURL() {
111         if (MCRSessionMgr.hasCurrentSession()) {
112             MCRSession session = MCRSessionMgr.getCurrentSession();
113             Object value = session.get(BASE_URL_ATTRIBUTE);
114             if (value != null) {
115                 LOGGER.debug("Returning BaseURL {} from user session.", value);
116                 return value.toString();
117             }
118         }
119         return BASE_URL;
120     }
121 
122     public static String getHostIP() {
123         return BASE_HOST_IP;
124     }
125 
126     /**
127      * returns the base URL of the mycore system. This method uses the request to 'calculate' the right baseURL.
128      * Generally it is sufficent to use {@link #getBaseURL()} instead.
129      */
130     public static String getBaseURL(ServletRequest req) {
131         HttpServletRequest request = (HttpServletRequest) req;
132         String scheme = req.getScheme();
133         String host = req.getServerName();
134         int serverPort = req.getServerPort();
135         String path = request.getContextPath() + "/";
136 
137         if (TRUSTED_PROXIES.contains(req.getRemoteAddr())) {
138             scheme = Optional.ofNullable(request.getHeader(PROXY_HEADER_SCHEME)).orElse(scheme);
139             host = Optional.ofNullable(request.getHeader(PROXY_HEADER_HOST)).orElse(host);
140             serverPort = Optional.ofNullable(request.getHeader(PROXY_HEADER_PORT))
141                 .map(Integer::parseInt)
142                 .orElse(serverPort);
143             path = Optional.ofNullable(request.getHeader(PROXY_HEADER_PATH)).orElse(path);
144             if (!path.endsWith("/")) {
145                 path += "/";
146             }
147         }
148         StringBuilder webappBase = new StringBuilder(scheme);
149         webappBase.append("://");
150         webappBase.append(host);
151         if (!("http".equals(scheme) && serverPort == 80 || "https".equals(scheme) && serverPort == 443)) {
152             webappBase.append(':').append(serverPort);
153         }
154         webappBase.append(path);
155         return webappBase.toString();
156     }
157 
158     public static synchronized void prepareBaseURLs(String baseURL) {
159         BASE_URL = MCRConfiguration2.getString("MCR.baseurl").orElse(baseURL);
160         if (!BASE_URL.endsWith("/")) {
161             BASE_URL = BASE_URL + "/";
162         }
163         try {
164             URL url = new URL(BASE_URL);
165             InetAddress baseHost = InetAddress.getByName(url.getHost());
166             BASE_HOST_IP = baseHost.getHostAddress();
167         } catch (MalformedURLException e) {
168             LOGGER.error("Can't create URL from String {}", BASE_URL);
169         } catch (UnknownHostException e) {
170             LOGGER.error("Can't find host IP for URL {}", BASE_URL);
171         }
172     }
173 
174     public static void configureSession(MCRSession session, HttpServletRequest request, HttpServletResponse response) {
175         final MCRServletJob servletJob = new MCRServletJob(request, response);
176         setAsCurrent(session, servletJob);
177         // language
178         getProperty(request, "lang")
179             .filter(MCRTranslation.getAvailableLanguages()::contains)
180             .ifPresent(session::setCurrentLanguage);
181 
182         // Set the IP of the current session
183         if (session.getCurrentIP().length() == 0) {
184             session.setCurrentIP(getRemoteAddr(request));
185         }
186 
187         // set BASE_URL_ATTRIBUTE to MCRSession
188         if (request.getAttribute(BASE_URL_ATTRIBUTE) != null) {
189             session.put(BASE_URL_ATTRIBUTE, request.getAttribute(BASE_URL_ATTRIBUTE));
190         }
191 
192         // Store XSL.*.SESSION parameters to MCRSession
193         putParamsToSession(request);
194     }
195 
196     /**
197      * @param request current request to get property from
198      * @param name of request {@link HttpServletRequest#getAttribute(String) attribute} or 
199      * {@link HttpServletRequest#getParameter(String) parameter}
200      * @return an Optional that is either empty or contains a trimmed non-empty String that is either
201      *  the value of the request attribute or a parameter (in that order) with the given <code>name</code>.
202      */
203     public static Optional<String> getProperty(HttpServletRequest request, String name) {
204         return Stream.<Supplier<Object>>of(
205             () -> request.getAttribute(name),
206             () -> request.getParameter(name))
207             .map(Supplier::get)
208             .filter(Objects::nonNull)
209             .map(Object::toString)
210             .map(String::trim)
211             .filter(s -> !s.isEmpty())
212             .findFirst();
213     }
214 
215     /**
216      * Returns the IP address of the client that made the request. When a trusted proxy server was used, e. g. a local
217      * Apache mod_proxy in front of Tomcat, the value of the last entry in the HTTP header X_FORWARDED_FOR is returned,
218      * otherwise the REMOTE_ADDR is returned. The list of trusted proxy IPs can be configured using the property
219      * MCR.Request.TrustedProxies, which is a List of IP addresses separated by blanks and/or comma.
220      */
221     public static String getRemoteAddr(HttpServletRequest req) {
222         String remoteAddress = req.getRemoteAddr();
223         if (TRUSTED_PROXIES.contains(remoteAddress)) {
224             String xff = getXForwardedFor(req);
225             if (xff != null) {
226                 remoteAddress = xff;
227             }
228         }
229         return remoteAddress;
230     }
231 
232     /**
233      * Saves this instance as the 'current' servlet job.
234      *
235      * Can be retrieved afterwards by {@link #getCurrentServletJob()}.
236      * @throws IllegalStateException if {@link MCRSessionMgr#hasCurrentSession()} returns false
237      */
238     private static void setAsCurrent(MCRSession session, MCRServletJob job) throws IllegalStateException {
239         session.setFirstURI(() -> URI.create(job.getRequest().getRequestURI()));
240         CURRENT_SERVLET_JOB.set(Map.entry(session.getID(), job));
241     }
242 
243     /**
244      * Returns the instance saved for the current thread via
245      * {@link #configureSession(MCRSession, HttpServletRequest, HttpServletResponse)}.
246      * @return {@link Optional#empty()} if no servlet job is available for the current {@link MCRSession}
247      */
248     public static Optional<MCRServletJob> getCurrentServletJob() {
249         final Map.Entry<String, MCRServletJob> servletJob = CURRENT_SERVLET_JOB.get();
250         final Optional<MCRServletJob> rv = Optional.ofNullable(servletJob)
251             .filter(job -> MCRSessionMgr.hasCurrentSession())
252             .filter(job -> MCRSessionMgr.getCurrentSession().getID().equals(job.getKey()))
253             .map(Map.Entry::getValue);
254         if (rv.isEmpty()) {
255             CURRENT_SERVLET_JOB.remove();
256         }
257         return rv;
258     }
259 
260     /**
261      * Get header to check if request comes in via a proxy. There are two possible header names
262      */
263     private static String getXForwardedFor(HttpServletRequest req) {
264         String xff = req.getHeader(PROXY_HEADER_REMOTE_IP);
265         if ((xff == null) || xff.trim().isEmpty()) {
266             xff = req.getHeader(PROXY_HEADER_REMOTE_IP);
267         }
268         if ((xff == null) || xff.trim().isEmpty()) {
269             return null;
270         }
271 
272         // X_FORWARDED_FOR can be comma separated list of hosts,
273         // if so, take last entry, all others are not reliable because
274         // any client may have set the header to any value.
275 
276         LOGGER.debug("{} complete: {}", PROXY_HEADER_REMOTE_IP, xff);
277         StringTokenizer st = new StringTokenizer(xff, " ,;");
278         while (st.hasMoreTokens()) {
279             xff = st.nextToken().trim();
280         }
281         LOGGER.debug("{} last: {}", PROXY_HEADER_REMOTE_IP, xff);
282         return xff;
283     }
284 
285     private static void putParamsToSession(HttpServletRequest request) {
286         MCRSession mcrSession = MCRSessionMgr.getCurrentSession();
287 
288         for (Enumeration<String> e = request.getParameterNames(); e.hasMoreElements();) {
289             String name = e.nextElement();
290             if (name.startsWith("XSL.") && name.endsWith(".SESSION")) {
291                 String key = name.substring(0, name.length() - 8);
292                 // parameter is not empty -> store
293                 if (!request.getParameter(name).trim().equals("")) {
294                     mcrSession.put(key, request.getParameter(name));
295                     LOGGER.debug("Found HTTP-Req.-Parameter {}={} that should be saved in session, safed {}={}", name,
296                         request.getParameter(name), key, request.getParameter(name));
297                 } else {
298                     // paramter is empty -> do not store and if contained in
299                     // session, remove from it
300                     if (mcrSession.get(key) != null) {
301                         mcrSession.deleteObject(key);
302                     }
303                 }
304             }
305         }
306         for (Enumeration<String> e = request.getAttributeNames(); e.hasMoreElements();) {
307             String name = e.nextElement();
308             if (name.startsWith("XSL.") && name.endsWith(".SESSION")) {
309                 String key = name.substring(0, name.length() - 8);
310                 // attribute is not empty -> store
311                 if (!request.getAttribute(name).toString().trim().equals("")) {
312                     mcrSession.put(key, request.getAttribute(name));
313                     LOGGER.debug("Found HTTP-Req.-Attribute {}={} that should be saved in session, safed {}={}", name,
314                         request.getParameter(name), key, request.getParameter(name));
315                 } else {
316                     // attribute is empty -> do not store and if contained in
317                     // session, remove from it
318                     if (mcrSession.get(key) != null) {
319                         mcrSession.deleteObject(key);
320                     }
321                 }
322             }
323         }
324     }
325 
326     /**
327      * Builds a list of trusted proxy IPs from MCR.Request.TrustedProxies. The IP address of the local host is
328      * automatically added to this list.
329      * 
330      * @return
331      */
332     private static TreeSet<String> getTrustedProxies() {
333         // Always trust the local host
334         return Stream
335             .concat(Stream.of("localhost", URI.create(getBaseURL()).getHost()), MCRConfiguration2
336                 .getString("MCR.Request.TrustedProxies").map(MCRConfiguration2::splitValue).orElse(Stream.empty()))
337             .distinct()
338             .peek(proxy -> LOGGER.debug("Trusted proxy: {}", proxy))
339             .map(host -> {
340                 try {
341                     return InetAddress.getAllByName(host);
342                 } catch (UnknownHostException e) {
343                     LOGGER.warn("Unknown host: {}", host);
344                     return null;
345                 }
346             }).filter(Objects::nonNull)
347             .flatMap(Stream::of)
348             .map(InetAddress::getHostAddress)
349             .collect(Collectors.toCollection(TreeSet::new));
350     }
351 
352     /**
353      * Sets cache-control, last-modified and expires parameter to the response header.
354      * Use this method when the client should cache the response data.
355      * 
356      * @param response the response data to cache
357      * @param cacheTime how long to cache
358      * @param lastModified when the data was last modified
359      * @param useExpire true if 'Expire' header should be set
360      */
361     public static void writeCacheHeaders(HttpServletResponse response, long cacheTime, long lastModified,
362         boolean useExpire) {
363         response.setHeader("Cache-Control", "public, max-age=" + cacheTime);
364         response.setDateHeader("Last-Modified", lastModified);
365         if (useExpire) {
366             Date expires = new Date(System.currentTimeMillis() + cacheTime * 1000);
367             LOGGER.debug("Last-Modified: {}, expire on: {}", new Date(lastModified), expires);
368             response.setDateHeader("Expires", expires.getTime());
369         }
370     }
371 
372     public static Optional<File> getWebAppBaseDir(ServletContext ctx) {
373         return Optional.ofNullable(ctx.getRealPath("/")).map(File::new);
374     }
375 
376     /**
377      * Checks if the <code>newIP</code> address matches the session of <code>lastIP</code> address.
378      *
379      * Usually this is only <code>true</code> if both addresses are equal by {@link InetAddress#equals(Object)}.
380      * This method is called to detect if a session is stolen by a 3rd party.
381      * There are two properties (with their default value) to modify this behavior and specify netmasks:
382      * <pre>
383      * MCR.Servlet.Session.NetMask.IPv4=255.255.255.255
384      * MCR.Servlet.Session.NetMask.IPv6=FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF:FFFF
385      * </pre>
386      *
387      * @param lastIP IP address from former request
388      * @param newIP IP address from current request
389      * @return
390      * @throws UnknownHostException if <code>lastIP</code> or <code>newIP</code> are not valid IP addresses.
391      */
392     public static boolean isIPAddrAllowed(String lastIP, String newIP) throws UnknownHostException {
393         InetAddress lastIPAddress = InetAddress.getByName(lastIP);
394         InetAddress newIPAddress = InetAddress.getByName(newIP);
395         byte[] lastIPMask = decideNetmask(lastIPAddress);
396         byte[] newIPMask = decideNetmask(newIPAddress);
397         lastIPAddress = InetAddress.getByAddress(filterIPByNetmask(lastIPAddress.getAddress(), lastIPMask));
398         newIPAddress = InetAddress.getByAddress(filterIPByNetmask(newIPAddress.getAddress(), newIPMask));
399         if (lastIPAddress.equals(newIPAddress)) {
400             return true;
401         }
402         String hostIP = getHostIP();
403         InetAddress hostIPAddress = InetAddress.getByName(hostIP);
404         byte[] hostIPMask = decideNetmask(hostIPAddress);
405         hostIPAddress = InetAddress.getByAddress(filterIPByNetmask(hostIPAddress.getAddress(), hostIPMask));
406         return newIPAddress.equals(hostIPAddress);
407     }
408 
409     private static byte[] filterIPByNetmask(final byte[] ip, final byte[] mask) {
410         for (int i = 0; i < ip.length; i++) {
411             ip[i] = (byte) (ip[i] & mask[i]);
412         }
413         return ip;
414     }
415 
416     private static byte[] decideNetmask(InetAddress ip) throws MCRException {
417         if (hasIPVersion(ip, 4)) {
418             return SESSION_NETMASK_IPV4;
419         } else if (hasIPVersion(ip, 6)) {
420             return SESSION_NETMASK_IPV6;
421         } else {
422             throw new MCRException("Unknown or unidentifiable version of ip: " + ip);
423         }
424     }
425 
426     private static Boolean hasIPVersion(InetAddress ip, int version) {
427         int byteLength;
428         switch (version) {
429         case 4:
430             byteLength = 4;
431             break;
432         case 6:
433             byteLength = 16;
434             break;
435         default:
436             throw new IndexOutOfBoundsException("Unknown ip version: " + version);
437         }
438         return ip.getAddress().length == byteLength;
439     }
440 
441     private static void addSessionListener() {
442         MCRSessionMgr.addSessionListener(event -> {
443             switch (event.getType()) {
444             case passivated:
445             case destroyed:
446                 CURRENT_SERVLET_JOB.remove();
447                 break;
448             default:
449                 break;
450             }
451         });
452     }
453 
454 }