View Javadoc
1   /*
2    * This file is part of Waarp Project (named also Waarp or GG).
3    *
4    *  Copyright (c) 2019, Waarp SAS, and individual contributors by the @author
5    *  tags. See the COPYRIGHT.txt in the distribution for a full listing of
6    * individual contributors.
7    *
8    *  All Waarp Project is free software: you can redistribute it and/or
9    * modify it under the terms of the GNU General Public License as published by
10   * the Free Software Foundation, either version 3 of the License, or (at your
11   * option) any later version.
12   *
13   * Waarp is distributed in the hope that it will be useful, but WITHOUT ANY
14   * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
15   * A PARTICULAR PURPOSE. See the GNU General Public License for more details.
16   *
17   *  You should have received a copy of the GNU General Public License along with
18   * Waarp . If not, see <http://www.gnu.org/licenses/>.
19   */
20  
21  package org.waarp.openr66.protocol.http.restv2.resthandlers;
22  
23  import io.cdap.http.HandlerHook;
24  import io.cdap.http.HttpResponder;
25  import io.cdap.http.internal.HandlerInfo;
26  import io.netty.handler.codec.http.DefaultHttpHeaders;
27  import io.netty.handler.codec.http.HttpRequest;
28  import io.netty.handler.codec.http.HttpResponseStatus;
29  import org.joda.time.DateTime;
30  import org.waarp.common.crypto.HmacSha256;
31  import org.waarp.common.database.exception.WaarpDatabaseException;
32  import org.waarp.common.logging.WaarpLogger;
33  import org.waarp.common.logging.WaarpLoggerFactory;
34  import org.waarp.common.role.RoleDefault;
35  import org.waarp.common.role.RoleDefault.ROLE;
36  import org.waarp.common.utility.BaseXx;
37  import org.waarp.common.utility.ParametersChecker;
38  import org.waarp.common.utility.WaarpStringUtils;
39  import org.waarp.openr66.dao.DAOFactory;
40  import org.waarp.openr66.dao.HostDAO;
41  import org.waarp.openr66.dao.exception.DAOConnectionException;
42  import org.waarp.openr66.dao.exception.DAONoDataException;
43  import org.waarp.openr66.database.data.DbHostAuth;
44  import org.waarp.openr66.pojo.Host;
45  import org.waarp.openr66.protocol.http.restv2.RestServiceInitializer;
46  import org.waarp.openr66.protocol.http.restv2.converters.HostConfigConverter;
47  import org.waarp.openr66.protocol.http.restv2.dbhandlers.AbstractRestDbHandler;
48  import org.waarp.openr66.protocol.http.restv2.dbhandlers.RequiredRole;
49  
50  import javax.ws.rs.Consumes;
51  import javax.ws.rs.InternalServerErrorException;
52  import javax.ws.rs.NotAllowedException;
53  import javax.ws.rs.core.MediaType;
54  import java.lang.reflect.Method;
55  import java.text.ParseException;
56  import java.util.Arrays;
57  import java.util.List;
58  import java.util.regex.Matcher;
59  import java.util.regex.Pattern;
60  
61  import static io.netty.handler.codec.http.HttpMethod.*;
62  import static io.netty.handler.codec.http.HttpResponseStatus.*;
63  import static javax.ws.rs.core.HttpHeaders.*;
64  import static javax.ws.rs.core.MediaType.*;
65  import static org.glassfish.jersey.message.internal.HttpHeaderReader.*;
66  import static org.glassfish.jersey.message.internal.MediaTypes.*;
67  import static org.waarp.common.role.RoleDefault.ROLE.*;
68  import static org.waarp.openr66.protocol.configuration.Configuration.*;
69  import static org.waarp.openr66.protocol.http.restv2.RestConstants.*;
70  
71  /**
72   * This class defines hooks called before and after the corresponding {@link
73   * AbstractRestDbHandler} when a
74   * request is made. These hooks check the user authentication and privileges, as
75   * well as the request content
76   * type.
77   */
78  public class RestHandlerHook implements HandlerHook {
79  
80    /**
81     * Tells if the REST request authentication is activated.
82     */
83    private final boolean authenticated;
84  
85    /**
86     * Stores the key used for HMAC authentication.
87     */
88    private final HmacSha256 hmac;
89  
90    /**
91     * The time (in ms) for which a HMAC signed request is valid.
92     */
93    private final long delay;
94  
95    /**
96     * The logger for all events.
97     */
98    private static final WaarpLogger logger =
99        WaarpLoggerFactory.getLogger(RestHandlerHook.class);
100 
101   /**
102    * Hook called before a request handler is called. Checks if the REST method
103    * is active in the CRUD
104    * configuration, checks the request's content type, and finally checks the
105    * user authentication (if
106    * activated).
107    *
108    * @param request the HttpRequest currently being processed
109    * @param responder the HttpResponder sending the response
110    * @param handlerInfo the information about the handler to which the
111    *     request will be sent for processing
112    *
113    * @return {@code true} if the request can be handed to the handler, or
114    *     {@code false} if an error occurred and
115    *     a response must be sent immediately.
116    */
117   @Override
118   public final boolean preCall(final HttpRequest request,
119                                final HttpResponder responder,
120                                final HandlerInfo handlerInfo) {
121 
122     try {
123       final AbstractRestDbHandler handler = getHandler(handlerInfo);
124       if (!handler.checkCRUD(request)) {
125         responder.sendStatus(METHOD_NOT_ALLOWED);
126         return false;
127       }
128 
129       final Method handleMethod = getMethod(handler, handlerInfo);
130       if (authenticated && !request.method().equals(OPTIONS)) {
131         final String user = checkCredentials(request);
132         if (!checkAuthorization(user, handleMethod)) {
133           responder.sendStatus(FORBIDDEN);
134           return false;
135         }
136       }
137 
138       final List<MediaType> expectedTypes = getExpectedMediaTypes(handleMethod);
139       if (!checkContentType(request, expectedTypes)) {
140         final DefaultHttpHeaders headers = new DefaultHttpHeaders();
141         headers.add(ACCEPT, convertToString(expectedTypes));
142         responder.sendStatus(UNSUPPORTED_MEDIA_TYPE, headers);
143         return false;
144       }
145 
146       return true;
147     } catch (final NotAllowedException e) {
148       logger.info(e.getMessage());
149       final DefaultHttpHeaders headers = new DefaultHttpHeaders();
150       headers.add(WWW_AUTHENTICATE, "Basic, HMAC");
151       responder.sendStatus(UNAUTHORIZED, headers);
152     } catch (final InternalServerErrorException e) {
153       logger.error(e);
154       responder.sendStatus(INTERNAL_SERVER_ERROR);
155     } catch (final Throwable t) {
156       logger.error("RESTv2 Unexpected exception caught ->", t);
157       responder.sendStatus(INTERNAL_SERVER_ERROR);
158     }
159     return false;
160   }
161 
162   /**
163    * Returns the {@link AbstractRestDbHandler} instance corresponding to the
164    * info given as parameter.
165    *
166    * @param handlerInfo the information about the handler
167    *
168    * @return the corresponding AbstractRestDbHandler
169    *
170    * @throws IllegalArgumentException if the given handler does not
171    *     exist.
172    */
173   private AbstractRestDbHandler getHandler(final HandlerInfo handlerInfo) {
174     for (final AbstractRestDbHandler h : RestServiceInitializer.handlers) {
175       if (h.getClass().getName().equals(handlerInfo.getHandlerName())) {
176         return h;
177       }
178     }
179     throw new IllegalArgumentException(
180         "The handler " + handlerInfo.getHandlerName() + " does not exist.");
181   }
182 
183   /**
184    * Returns the {@link Method} object corresponding to the handler method
185    * chosen to process the request. This
186    * is needed to check for the annotations present on the method.
187    *
188    * @param handler the handler chosen to process the request
189    * @param handlerInfo the information about the handler
190    *
191    * @return the corresponding Method object
192    *
193    * @throws IllegalArgumentException if the given method name does
194    *     not exist
195    */
196   private Method getMethod(final AbstractRestDbHandler handler,
197                            final HandlerInfo handlerInfo) {
198     Method method = null;
199     for (final Method m : handler.getClass().getMethods()) {//NOSONAR
200       if (m.getName().equals(handlerInfo.getMethodName()) &&
201           m.getParameterTypes()[0] == HttpRequest.class &&
202           m.getParameterTypes()[1] == HttpResponder.class) {
203         method = m;
204         break;
205       }
206     }
207     if (method == null) {
208       throw new IllegalArgumentException(
209           "The handler " + handlerInfo.getHandlerName() +
210           " does not have a method " + handlerInfo.getMethodName());
211     }
212     return method;
213   }
214 
215   /**
216    * Return a List of all the {@link MediaType} accepted by the given {@link
217    * Method}. This list is based on the
218    * types indicated by the method's {@link Consumes} annotation. If the
219    * annotation is absent, the method will
220    * be assumed to accept any type.
221    *
222    * @param method the Method to inspect
223    *
224    * @return the list of all acceptable MediaType
225    */
226   private List<MediaType> getExpectedMediaTypes(final Method method) {
227     List<MediaType> consumedTypes = WILDCARD_TYPE_SINGLETON_LIST;
228 
229     if (method.isAnnotationPresent(Consumes.class)) {
230       consumedTypes = createFrom(method.getAnnotation(Consumes.class));
231     } else {
232       logger.warn(String.format(
233           "[RESTv2] The method %s of handler %s is missing " +
234           "a '%s' annotation for the expected request content type, " +
235           "the default value '%s' was given instead.", method.getName(),
236           method.getDeclaringClass().getSimpleName(),
237           Consumes.class.getSimpleName(), WILDCARD));
238     }
239 
240     return consumedTypes;
241   }
242 
243   /**
244    * Checks if the content type of the request is compatible with the expected
245    * content type of the method
246    * called. If no content type header can be found, the request will be
247    * assumed to have a correct content type.
248    *
249    * @param request the HttpRequest sent by the user
250    * @param consumedTypes a list of the acceptable MediaType for the
251    *     request
252    *
253    * @return {@code true} if the request content type is acceptable, {@code
254    *     false} otherwise.
255    */
256   private boolean checkContentType(final HttpRequest request,
257                                    final List<MediaType> consumedTypes) {
258 
259     final String contentTypeHeader = request.headers().get(CONTENT_TYPE);
260     if (ParametersChecker.isEmpty(contentTypeHeader)) {
261       return true;
262     }
263 
264     final MediaType requestType;
265     try {
266       requestType = readAcceptMediaType(contentTypeHeader).get(0);
267     } catch (final ParseException e) {
268       return false;
269     }
270     for (final MediaType consumedType : consumedTypes) {
271       if (requestType.isCompatible(consumedType)) {
272         return true;
273       }
274     }
275     return false;
276   }
277 
278   /**
279    * Checks if the user making the request does exist. If the user does exist,
280    * this method returns the user's
281    * name, otherwise throws a {@link NotAllowedException}.
282    *
283    * @param request the request currently being processed
284    *
285    * @return the user's name
286    *
287    * @throws InternalServerErrorException if an unexpected error
288    *     occurred
289    * @throws NotAllowedException if the user making the request does
290    *     not exist
291    */
292   protected final String checkCredentials(final HttpRequest request) {
293 
294     final String authorization = request.headers().get(AUTHORIZATION);
295 
296     if (authorization == null) {
297       throw new NotAllowedException("Missing header for authentication.");
298     }
299 
300     final Pattern basicPattern = Pattern.compile("(Basic) (\\w+=*)");
301     final Matcher basicMatcher = basicPattern.matcher(authorization);
302 
303     if (basicMatcher.find()) {
304 
305       final String[] credentials;
306       credentials = new String(BaseXx.getFromBase64(basicMatcher.group(2)),
307                                WaarpStringUtils.UTF8).split(":", 2);
308       if (credentials.length != 2) {
309         throw new NotAllowedException(
310             "Invalid header for Basic authentication.");
311       }
312       final String user = credentials[0];
313       final String pswd = credentials[1];
314 
315       HostDAO hostDAO = null;
316       Host host;
317       try {
318         hostDAO = DAO_FACTORY.getHostDAO(true);
319         if (!hostDAO.exist(user)) {
320           throw new NotAllowedException("User does not exist.");
321         }
322         host = hostDAO.select(user);
323       } catch (final DAOConnectionException e) {
324         throw new InternalServerErrorException(e);
325       } catch (final DAONoDataException e) {
326         throw new InternalServerErrorException(e);
327       } finally {
328         DAOFactory.closeDAO(hostDAO);
329       }
330 
331       final String key;
332       try {
333         key = configuration.getCryptoKey().cryptToHex(pswd);
334       } catch (final Exception e) {
335         throw new InternalServerErrorException(
336             "An error occurred when encrypting the password", e);
337       }
338       if (!Arrays.equals(host.getHostkey(),
339                          key.getBytes(WaarpStringUtils.UTF8))) {
340         throw new NotAllowedException("Invalid password.");
341       }
342 
343       return user;
344     }
345 
346     final String authUser = request.headers().get(AUTH_USER);
347     final String authDate = request.headers().get(AUTH_TIMESTAMP);
348 
349     final Pattern hmacPattern = Pattern.compile("(HMAC) (\\w+)");
350     final Matcher hmacMatcher = hmacPattern.matcher(authorization);
351 
352     if (hmacMatcher.find() && authUser != null && authDate != null) {
353 
354       final String authKey = hmacMatcher.group(2);
355       final DateTime requestDate;
356       try {
357         requestDate = DateTime.parse(authDate);
358       } catch (final IllegalArgumentException e) {
359         throw new NotAllowedException("Invalid authentication timestamp.");
360       }
361       final DateTime limitTime = requestDate.plus(delay);
362       if (DateTime.now().isAfter(limitTime)) {
363         throw new NotAllowedException("Authentication expired.");
364       }
365 
366       HostDAO hostDAO = null;
367       Host host;
368       try {
369         hostDAO = DAO_FACTORY.getHostDAO(true);
370         if (!hostDAO.exist(authUser)) {
371           throw new NotAllowedException("User does not exist.");
372         }
373         host = hostDAO.select(authUser);
374       } catch (final DAOConnectionException e) {
375         throw new InternalServerErrorException(e);
376       } catch (final DAONoDataException e) {
377         throw new InternalServerErrorException(e);
378       } finally {
379         DAOFactory.closeDAO(hostDAO);
380       }
381 
382       validateHMACCredentials(host, authDate, authUser, authKey);
383 
384       return authUser;
385     }
386 
387     throw new NotAllowedException("Missing credentials.");
388   }
389 
390   protected final void validateHMACCredentials(final Host host,
391                                                final String authDate,
392                                                final String authUser,
393                                                final String authKey)
394       throws InternalServerErrorException {
395     final String pswd;
396     try {
397       pswd = configuration.getCryptoKey().decryptHexInString(host.getHostkey());
398     } catch (final Exception e) {
399       throw new InternalServerErrorException(
400           "An error occurred when decrypting the password", e);
401     }
402 
403     final String key;
404     try {
405       key = hmac.cryptToHex(authDate + authUser + pswd);
406     } catch (final Exception e) {
407       throw new InternalServerErrorException(
408           "An error occurred when hashing the key", e);
409     }
410 
411     if (!key.equals(authKey)) {
412       throw new NotAllowedException("Invalid password.");
413     }
414   }
415 
416   /**
417    * Checks if the user given as argument is authorized to call the given
418    * method.
419    *
420    * @param user the name of the user making the request
421    * @param method the method called by the request
422    *
423    * @return {@code true} if the user is authorized to make the request,
424    *     {@code false} otherwise.
425    */
426   protected final boolean checkAuthorization(final String user,
427                                              final Method method) {
428     try {
429       final DbHostAuth hostAuth = new DbHostAuth(user);
430       if (hostAuth.isAdminrole()) {
431         return true;
432       }
433     } catch (final WaarpDatabaseException e) {
434       // ignore and continue
435     }
436 
437     ROLE requiredRole = NOACCESS;
438     if (method.isAnnotationPresent(RequiredRole.class)) {
439       requiredRole = method.getAnnotation(RequiredRole.class).value();
440     } else {
441       logger.warn(String.format("[RESTv2] The method %s of handler %s is " +
442                                 "missing a '%s' annotation for the minimum required role, " +
443                                 "the default value '%s' was given instead.",
444                                 method.getName(),
445                                 method.getDeclaringClass().getSimpleName(),
446                                 RequiredRole.class.getSimpleName(), NOACCESS));
447     }
448     if (requiredRole == NOACCESS) {
449       return true;
450     }
451 
452     final List<ROLE> roles = HostConfigConverter.getRoles(user);
453     if (roles != null) {
454       final RoleDefault roleDefault = new RoleDefault();
455       for (final ROLE roleType : roles) {
456         roleDefault.addRole(roleType);
457       }
458       return roleDefault.isContaining(requiredRole);
459     }
460     return false;
461   }
462 
463   /**
464    * Hook called after a request handler is called.
465    *
466    * @param httpRequest the request currently being processed
467    * @param httpResponseStatus the status of the http response
468    *     generated by the request handler
469    * @param handlerInfo information about the handler to which the
470    *     request was sent
471    */
472   @Override
473   public final void postCall(final HttpRequest httpRequest,
474                              final HttpResponseStatus httpResponseStatus,
475                              final HandlerInfo handlerInfo) {
476     // ignore
477   }
478 
479   /**
480    * Creates a HandlerHook which will check for authentication and signature
481    * on incoming request depending on
482    * the parameters.
483    *
484    * @param authenticated specifies if the HandlerHook will check
485    *     authentication
486    * @param hmac the key used for HMAC authentication
487    * @param delay the delay for which a HMAC signed request is valid
488    */
489   public RestHandlerHook(final boolean authenticated, final HmacSha256 hmac,
490                          final long delay) {
491     this.authenticated = authenticated;
492     this.hmac = hmac;
493     this.delay = delay;
494   }
495 }