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.restapi;
20  
21  import java.io.IOException;
22  import java.net.UnknownHostException;
23  import java.nio.charset.StandardCharsets;
24  import java.security.Principal;
25  import java.util.Arrays;
26  import java.util.Base64;
27  import java.util.LinkedHashMap;
28  import java.util.List;
29  import java.util.Map;
30  import java.util.Optional;
31  import java.util.stream.Collectors;
32  
33  import org.apache.commons.io.output.ProxyOutputStream;
34  import org.apache.logging.log4j.LogManager;
35  import org.apache.logging.log4j.Logger;
36  import org.mycore.common.MCRSession;
37  import org.mycore.common.MCRSessionMgr;
38  import org.mycore.common.MCRSystemUserInformation;
39  import org.mycore.common.MCRTransactionHelper;
40  import org.mycore.common.MCRUserInformation;
41  import org.mycore.common.config.MCRConfiguration2;
42  import org.mycore.frontend.MCRFrontendUtil;
43  import org.mycore.frontend.jersey.MCRJWTUtil;
44  import org.mycore.frontend.jersey.resources.MCRJWTResource;
45  import org.mycore.restapi.v1.MCRRestAPIAuthentication;
46  import org.mycore.restapi.v1.utils.MCRRestAPIUtil;
47  import org.mycore.user2.MCRUser;
48  import org.mycore.user2.MCRUserManager;
49  
50  import com.auth0.jwt.JWT;
51  import com.auth0.jwt.exceptions.JWTVerificationException;
52  import com.auth0.jwt.interfaces.Claim;
53  import com.auth0.jwt.interfaces.DecodedJWT;
54  
55  import jakarta.annotation.Priority;
56  import jakarta.servlet.http.HttpServletRequest;
57  import jakarta.ws.rs.InternalServerErrorException;
58  import jakarta.ws.rs.NotAuthorizedException;
59  import jakarta.ws.rs.Priorities;
60  import jakarta.ws.rs.container.ContainerRequestContext;
61  import jakarta.ws.rs.container.ContainerRequestFilter;
62  import jakarta.ws.rs.container.ContainerResponseContext;
63  import jakarta.ws.rs.container.ContainerResponseFilter;
64  import jakarta.ws.rs.core.Application;
65  import jakarta.ws.rs.core.CacheControl;
66  import jakarta.ws.rs.core.Context;
67  import jakarta.ws.rs.core.HttpHeaders;
68  import jakarta.ws.rs.core.Response;
69  import jakarta.ws.rs.core.SecurityContext;
70  import jakarta.ws.rs.ext.Provider;
71  import jakarta.ws.rs.ext.RuntimeDelegate;
72  
73  @Provider
74  @Priority(Priorities.AUTHENTICATION)
75  public class MCRSessionFilter implements ContainerRequestFilter, ContainerResponseFilter {
76  
77      public static final Logger LOGGER = LogManager.getLogger();
78  
79      private static final String PROP_RENEW_JWT = "mcr:renewJWT";
80  
81      private static final List<String> ALLOWED_JWT_SESSION_ATTRIBUTES = MCRConfiguration2
82          .getString("MCR.RestAPI.JWT.AllowedSessionAttributePrefixes").stream()
83          .flatMap(MCRConfiguration2::splitValue)
84          .collect(Collectors.toList());
85  
86      @Context
87      HttpServletRequest httpServletRequest;
88  
89      @Context
90      Application app;
91  
92      /**
93       * If request was authenticated via JSON Web Token add a new token if <code>aud</code> was
94       * {@link MCRRestAPIAuthentication#AUDIENCE}.
95       *
96       * If the response has a status code that represents a client error (4xx), the JSON Web Token is ommited.
97       * If the response already has a JSON Web Token no changes are made.
98       */
99      private static void addJWTToResponse(ContainerRequestContext requestContext,
100         ContainerResponseContext responseContext) {
101         MCRSession currentSession = MCRSessionMgr.getCurrentSession();
102         boolean renewJWT = Optional.ofNullable(requestContext.getProperty(PROP_RENEW_JWT))
103             .map(Boolean.class::cast)
104             .orElse(Boolean.FALSE);
105         Optional.ofNullable(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION))
106             .filter(s -> s.startsWith("Bearer "))
107             .filter(s -> !responseContext.getStatusInfo().getFamily().equals(Response.Status.Family.CLIENT_ERROR))
108             .filter(s -> responseContext.getHeaderString(HttpHeaders.AUTHORIZATION) == null)
109             .map(h -> renewJWT ? ("Bearer " + MCRRestAPIAuthentication
110                 .getToken(currentSession, currentSession.getCurrentIP())
111                 .orElseThrow(() -> new InternalServerErrorException("Could not get JSON Web Token"))) : h)
112             .ifPresent(h -> {
113                 responseContext.getHeaders().putSingle(HttpHeaders.AUTHORIZATION, h);
114                 //Authorization header may never be cached in public caches
115                 Optional.ofNullable(requestContext.getHeaderString(HttpHeaders.CACHE_CONTROL))
116                     .map(RuntimeDelegate.getInstance()
117                         .createHeaderDelegate(CacheControl.class)::fromString)
118                     .filter(cc -> !cc.isPrivate())
119                     .ifPresent(cc -> {
120                         cc.setPrivate(true);
121                         responseContext.getHeaders().putSingle(HttpHeaders.CACHE_CONTROL, cc);
122                     });
123             });
124     }
125 
126     @Override
127     public void filter(ContainerRequestContext requestContext) {
128         LOGGER.debug("Filter start.");
129         boolean isSecure = requestContext.getSecurityContext().isSecure();
130         if (MCRSessionMgr.hasCurrentSession()) {
131             throw new InternalServerErrorException("Session is already attached.");
132         }
133         MCRSessionMgr.unlock();
134         MCRSession currentSession = MCRSessionMgr.getCurrentSession(); //bind to this request
135         currentSession.setCurrentIP(MCRFrontendUtil.getRemoteAddr(httpServletRequest));
136         MCRTransactionHelper.beginTransaction();
137         //3 cases for authentication
138         Optional<MCRUserInformation> userInformation = Optional.empty();
139         String authorization = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
140         //1. no authentication
141         if (authorization == null) {
142             LOGGER.debug("No 'Authorization' header");
143             return;
144         }
145         //2. Basic Authentification
146         String basicPrefix = "Basic ";
147         if (authorization.startsWith(basicPrefix)) {
148             LOGGER.debug("Using 'Basic' authentication.");
149             byte[] encodedAuth = authorization.substring(basicPrefix.length()).trim()
150                 .getBytes(StandardCharsets.ISO_8859_1);
151             String userPwd = new String(Base64.getDecoder().decode(encodedAuth), StandardCharsets.ISO_8859_1);
152             if (userPwd.contains(":") && userPwd.length() > 1) {
153                 String[] upSplit = userPwd.split(":");
154                 String username = upSplit[0];
155                 String password = upSplit[1];
156                 userInformation = Optional.ofNullable(MCRUserManager.checkPassword(username, password))
157                     .map(MCRUserInformation.class::cast)
158                     .map(Optional::of)
159                     .orElseThrow(() -> {
160                         LinkedHashMap<String, String> attrs = new LinkedHashMap<>();
161                         attrs.put("error", "invalid_login");
162                         attrs.put("error_description", "Wrong login or password.");
163                         return new NotAuthorizedException(Response.status(Response.Status.UNAUTHORIZED)
164                             .header(HttpHeaders.WWW_AUTHENTICATE,
165                                 MCRRestAPIUtil.getWWWAuthenticateHeader(null, attrs, app))
166                             .build());
167                     });
168             }
169         }
170         //3. JWT
171         String bearerPrefix = "Bearer ";
172         if (authorization.startsWith(bearerPrefix)) {
173             LOGGER.debug("Using 'JSON Web Token' authentication.");
174             //get JWT
175             String token = authorization.substring(bearerPrefix.length()).trim();
176             //validate against secret
177             try {
178                 DecodedJWT jwt = JWT.require(MCRJWTUtil.getJWTAlgorithm())
179                     .build()
180                     .verify(token);
181                 //validate ip
182                 checkIPClaim(jwt.getClaim(MCRJWTUtil.JWT_CLAIM_IP), MCRFrontendUtil.getRemoteAddr(httpServletRequest));
183                 //validate in audience
184                 Optional<String> audience = jwt.getAudience().stream()
185                     .filter(s -> MCRJWTResource.AUDIENCE.equals(s) || MCRRestAPIAuthentication.AUDIENCE.equals(s))
186                     .findAny();
187                 if (audience.isPresent()) {
188                     switch (audience.get()) {
189                     case MCRJWTResource.AUDIENCE:
190                         MCRJWTResource.validate(token);
191                         break;
192                     case MCRRestAPIAuthentication.AUDIENCE:
193                         requestContext.setProperty(PROP_RENEW_JWT, true);
194                         MCRRestAPIAuthentication.validate(token);
195                         break;
196                     default:
197                         LOGGER.warn("Cannot validate JWT for '{}' audience.", audience.get());
198                     }
199                 }
200                 userInformation = Optional.of(new MCRJWTUserInformation(jwt));
201                 if (!ALLOWED_JWT_SESSION_ATTRIBUTES.isEmpty()) {
202                     for (Map.Entry<String, Claim> entry : jwt.getClaims().entrySet()) {
203                         if (entry.getKey().startsWith(MCRJWTUtil.JWT_SESSION_ATTRIBUTE_PREFIX)) {
204                             final String key = entry.getKey()
205                                 .substring(MCRJWTUtil.JWT_SESSION_ATTRIBUTE_PREFIX.length());
206                             for (String prefix : ALLOWED_JWT_SESSION_ATTRIBUTES) {
207                                 if (key.startsWith(prefix)) {
208                                     currentSession.put(key, entry.getValue().asString());
209                                     break;
210                                 }
211                             }
212                         }
213                     }
214                 }
215             } catch (JWTVerificationException e) {
216                 LOGGER.error(e.getMessage());
217                 LinkedHashMap<String, String> attrs = new LinkedHashMap<>();
218                 attrs.put("error", "invalid_token");
219                 attrs.put("error_description", e.getMessage());
220                 throw new NotAuthorizedException(e.getMessage(), e,
221                     MCRRestAPIUtil.getWWWAuthenticateHeader("Bearer", attrs, app));
222             }
223         }
224 
225         if (userInformation.isEmpty()) {
226             LOGGER.warn(() -> "Unsupported " + HttpHeaders.AUTHORIZATION + " header: " + authorization);
227         }
228 
229         userInformation
230             .ifPresent(ui -> {
231                 currentSession.setUserInformation(ui);
232                 requestContext.setSecurityContext(new MCRRestSecurityContext(ui, isSecure));
233             });
234         LOGGER.info("user detected: " + currentSession.getUserInformation().getUserID());
235     }
236 
237     private static void checkIPClaim(Claim ipClaim, String remoteAddr) {
238         try {
239             if (ipClaim.isNull() || !MCRFrontendUtil.isIPAddrAllowed(ipClaim.asString(), remoteAddr)) {
240                 throw new JWTVerificationException(
241                     "The Claim '" + MCRJWTUtil.JWT_CLAIM_IP + "' value doesn't match the required one.");
242             }
243         } catch (UnknownHostException e) {
244             throw new JWTVerificationException(
245                 "The Claim '" + MCRJWTUtil.JWT_CLAIM_IP + "' value doesn't match the required one.", e);
246         }
247     }
248 
249     @Override
250     public void filter(ContainerRequestContext requestContext, ContainerResponseContext responseContext) {
251         LOGGER.debug("ResponseFilter start");
252         try {
253             MCRSessionMgr.unlock();
254             MCRSession currentSession = MCRSessionMgr.getCurrentSession();
255             if (responseContext.getStatus() == Response.Status.FORBIDDEN.getStatusCode() && currentSession
256                 .getUserInformation().getUserID().equals(MCRSystemUserInformation.getGuestInstance().getUserID())) {
257                 LOGGER.debug("Guest detected, change response from FORBIDDEN to UNAUTHORIZED.");
258                 responseContext.setStatus(Response.Status.UNAUTHORIZED.getStatusCode());
259                 responseContext.getHeaders().putSingle(HttpHeaders.WWW_AUTHENTICATE,
260                     MCRRestAPIUtil.getWWWAuthenticateHeader("Basic", null, app));
261             }
262             if (responseContext.getStatus() == Response.Status.UNAUTHORIZED.getStatusCode()
263                 && doNotWWWAuthenticate(requestContext)) {
264                 LOGGER.debug("Remove {} header.", HttpHeaders.WWW_AUTHENTICATE);
265                 responseContext.getHeaders().remove(HttpHeaders.WWW_AUTHENTICATE);
266             }
267             addJWTToResponse(requestContext, responseContext);
268             if (responseContext.hasEntity()) {
269                 responseContext.setEntityStream(new ProxyOutputStream(responseContext.getEntityStream()) {
270                     @Override
271                     public void close() throws IOException {
272                         LOGGER.debug("Closing EntityStream");
273                         try {
274                             super.close();
275                         } finally {
276                             closeSessionIfNeeded();
277                             LOGGER.debug("Closing EntityStream done");
278                         }
279                     }
280                 });
281             } else {
282                 LOGGER.debug("No Entity in response, closing MCRSession");
283                 closeSessionIfNeeded();
284             }
285         } finally {
286             LOGGER.debug("ResponseFilter stop");
287         }
288     }
289 
290     //returns true for Ajax-Requests or requests for embedded images
291     private static boolean doNotWWWAuthenticate(ContainerRequestContext requestContext) {
292         return "XMLHttpRequest".equals(requestContext.getHeaderString("X-Requested-With")) ||
293             requestContext.getAcceptableMediaTypes()
294                 .stream()
295                 .findFirst()
296                 .filter(m -> "image".equals(m.getType()))
297                 .isPresent();
298     }
299 
300     private static void closeSessionIfNeeded() {
301         if (MCRSessionMgr.hasCurrentSession()) {
302             MCRSession currentSession = MCRSessionMgr.getCurrentSession();
303             try {
304                 if (MCRTransactionHelper.isTransactionActive()) {
305                     LOGGER.debug("Active MCRSession and JPA-Transaction found. Clearing up");
306                     if (MCRTransactionHelper.transactionRequiresRollback()) {
307                         MCRTransactionHelper.rollbackTransaction();
308                     } else {
309                         MCRTransactionHelper.commitTransaction();
310                     }
311                 } else {
312                     LOGGER.debug("Active MCRSession found. Clearing up");
313                 }
314             } finally {
315                 MCRSessionMgr.releaseCurrentSession();
316                 currentSession.close();
317                 LOGGER.debug("Session closed.");
318             }
319         }
320     }
321 
322     private static class MCRJWTUserInformation implements MCRUserInformation {
323 
324         private final DecodedJWT jwt;
325 
326         MCRJWTUserInformation(DecodedJWT token) {
327             this.jwt = token;
328         }
329 
330         @Override
331         public String getUserID() {
332             return jwt.getSubject();
333         }
334 
335         @Override
336         public boolean isUserInRole(String role) {
337             return Arrays.asList(jwt.getClaim("mcr:roles").asArray(String.class)).contains(role);
338         }
339 
340         @Override
341         public String getUserAttribute(String attribute) {
342             if (MCRUserInformation.ATT_REAL_NAME.equals(attribute)) {
343                 return jwt.getClaim("name").asString();
344             }
345             if (MCRUserInformation.ATT_EMAIL.equals(attribute)) {
346                 return jwt.getClaim("email").asString();
347             }
348             return jwt.getClaim(MCRJWTUtil.JWT_USER_ATTRIBUTE_PREFIX + attribute).asString();
349         }
350     }
351 
352     private static class MCRRestSecurityContext implements SecurityContext {
353         private final MCRUserInformation ui;
354 
355         private final boolean isSecure;
356 
357         private final Principal principal;
358 
359         MCRRestSecurityContext(MCRUserInformation ui, boolean isSecure) {
360             this.principal = ui::getUserID;
361             this.ui = ui;
362             this.isSecure = isSecure;
363         }
364 
365         @Override
366         public Principal getUserPrincipal() {
367             return principal;
368         }
369 
370         @Override
371         public boolean isUserInRole(String role) {
372             return ui.isUserInRole(role);
373         }
374 
375         @Override
376         public boolean isSecure() {
377             return isSecure;
378         }
379 
380         @Override
381         public String getAuthenticationScheme() {
382             if (ui.getUserID().equals(MCRSystemUserInformation.getGuestInstance().getUserID())) {
383                 return null;
384             }
385             if (ui instanceof MCRUser) {
386                 return SecurityContext.BASIC_AUTH;
387             }
388             if (ui instanceof MCRJWTUserInformation) {
389                 return "BEARER";
390             }
391             return null;
392         }
393     }
394 }