package hirondelle.web4j.security;

import static hirondelle.web4j.util.Consts.FAILS;
import hirondelle.web4j.BuildImpl;
import hirondelle.web4j.action.Action;
import hirondelle.web4j.action.Operation;
import hirondelle.web4j.model.BadRequestException;
import hirondelle.web4j.model.Id;
import hirondelle.web4j.readconfig.Config;
import hirondelle.web4j.readconfig.ConfigReader;
import hirondelle.web4j.request.RequestParameter;
import hirondelle.web4j.request.RequestParser;
import hirondelle.web4j.util.Util;

import java.util.Collection;
import java.util.Enumeration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Set;
import java.util.logging.Logger;

import javax.servlet.ServletConfig;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;

/**
 Default implementation of {@link ApplicationFirewall}.

 <P>Upon startup, this class will inspect all {@link Action}s in the application. 
 All <tt>public static final</tt> {@link hirondelle.web4j.request.RequestParameter} fields accessible 
 to each {@link Action} will be collected, and treated here as the set of acceptable 
 {@link RequestParameter}s for each {@link Action} class. Thus, when this class is used to implement 
 {@link ApplicationFirewall},  <span class="highlight">each {@link Action} must declare all expected request 
 parameters as a <tt>public static final</tt> {@link RequestParameter} field, in order to pass hard validation.</span> 

 <h3>File Upload Forms</h3>
 If a POSTed request includes one or more file upload controls, then the underlying HTTP request has 
 a completely different structure from a regular request having no file upload controls.
 Unfortunately, the Servlet API has very poor support for forms that include a file upload control: only the raw underlying request is available, <em>in an unparsed form</em>. 
 For such forms, POSTed data is not available in the usual way, and by default <tt>request.getParameter(String)</tt> will return <tt>null</tt> -  
 <em>not only for the file upload control, but for all controls in the form</em>. 
 
 <P>An elegant way around this problem involves <em>wrapping</em> the request, 
 using {@link javax.servlet.http.HttpServletRequestWrapper}, such that POSTed data is parsed and made 
 available through the usual <tt>request</tt> methods.
 If such a wrapper is used, then file upload forms can be handled in much the same way as any other form.
  
 <P>To indicate to this class if such a wrapper is being used for file upload requests, use the <tt>FullyValidateFileUploads</tt> setting 
 in <tt>web.xml</tt>. 

 <P>Settings in <tt>web.xml</tt> affecting this class :
 <ul>
 <li><tt>MaxHttpRequestSize</tt>
 <li><tt>MaxFileUploadRequestSize</tt>
 <li><tt>MaxRequestParamValueSize</tt> (used by {@link hirondelle.web4j.request.RequestParameter})
 <li><tt>SpamDetectionInFirewall</tt>
 <li><tt>FullyValidateFileUploads</tt>
 </ul>
 
 <P>The above settings control the validations performed by this class :
 <table   border="1" cellpadding="3" cellspacing="0">
  <tr>
   <th>Check</th>
   <th>Regular</th>
   <th>File Upload (Wrapped)</th>
   <th>File Upload</th>
  </tr>
  <tr>
   <td>Overall request size &lt;= <tt>MaxHttpRequestSize</tt> </td>
   <td>Y</td>
   <td>N</td>
   <td>N</td>
  </tr>
  <tr>
   <td>Overall request size &lt;= <tt>MaxFileUploadRequestSize</tt> </td>
   <td>N</td>
   <td>Y</td>
   <td>Y</td>
  </tr>
  <tr>
   <td>Every param <em>name</em> is among the {@link hirondelle.web4j.request.RequestParameter}s  for that {@link Action}</td>
   <td>Y</td>
   <td>Y&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>Every param <em>value</em> satifies {@link hirondelle.web4j.request.RequestParameter#isValidParamValue(String)}</td>
   <td>Y</td>
   <td>Y&#042;&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>If created with {@link hirondelle.web4j.request.RequestParameter#withLengthCheck(String)}, then param value size &lt;= <tt>MaxRequestParamValueSize</tt></td>
   <td>Y</td>
   <td>Y&#042;&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>If <tt>SpamDetectionInFirewall</tt> is on, then each param value is checked using the configured {@link hirondelle.web4j.security.SpamDetector}</td>
   <td>Y</td>
   <td>Y&#042;&#042;</td>
   <td>N</td>
  </tr>
  <tr>
   <td>If a request param named <tt>Operation</tt> exists and it returns <tt>true</tt> for {@link Operation#hasSideEffects()}, then the underlying request must be a <tt>POST</tt></td>
   <td>Y</td>
   <td>Y</td>
   <td>N</td>
  </tr>
  <tr>
   <td><a href='#CSRF'>CSRF Defenses</a></td>
   <td>Y</td>
   <td>Y</td>
   <td>N</td>
  </tr>
 </table>
 &#042; For file upload controls, the param name is checked only if the return value of <tt>getParameterNames()</tt> (for the wrapper) includes it.
 <br>&#042;&#042;Except for file upload controls. For file upload <em>controls</em>, no checks on the param <em>value</em> are made by this class.<br>
 
 <a name='CSRF'></a>
<h3>Defending Against CSRF Attacks</h3>
 If the usual WEB4J defenses against CSRF attacks are active (see package-level comments), 
 then <i>for every <tt>POST</tt> request executed within a session</i> the following will also be performed as a defense against CSRF attacks :
<ul>
 <li>validate that a request parameter named 
 {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY} is present. (This
 request parameter is deemed to be a special 'internal' parameter, and does not need to be explicitly declared in 
 your <tt>Action</tt> like other request parameters.)
 <li>validate that its value matches items stored in session scope. First check versus an item stored  
 under the key {@link hirondelle.web4j.security.CsrfFilter#FORM_SOURCE_ID_KEY}; if that check fails, then 
 check versus an item stored under the key 
 {@link hirondelle.web4j.security.CsrfFilter#PREVIOUS_FORM_SOURCE_ID_KEY}
</ul>

 See {@link hirondelle.web4j.security.CsrfFilter} for more information.
*/
public class ApplicationFirewallImpl implements ApplicationFirewall {

  /** Map actions to expected params. */
  public static void init(){
    fExpectedParams = ConfigReader.fetchPublicStaticFinalFields(Action.class, RequestParameter.class);
    fLogger.config("Expected Request Parameters per Web Action." + Util.logOnePerLine(fExpectedParams));
  }

  /**
   Perform checks on the incoming request. 
   
   <P>See class description for more information.
   
   <P>Subclasses may extend this implementation, following the form :
   <PRE>
  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    super(aAction, aRequestParser);
    //place additional validations here
    //for example, one might check that a Content-Length header is present,
    //or that all header values are within some size range
  }
   </PRE>
  */
  public void doHardValidation(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    if( aRequestParser.isFileUploadRequest() ){
      fLogger.fine("Validating a file upload request.");
    }
    checkForExtremeSize(aRequestParser);
    if ( aRequestParser.isFileUploadRequest() && ! fConfig.getFullyValidateFileUploads() )  {
      fLogger.fine("Unable to parse request in the usual way: file upload request is not wrapped. Cannot read parameter names and values. See FullyValidateFileUploads setting in web.xml.");
    }
    else {
      checkParamNamesAndValues(aAction, aRequestParser);
      checkSideEffectOperations(aAction, aRequestParser);
      defendAgainstCSRFAttacks(aRequestParser);
    }
  }
  
  // PRIVATE 

  private Config fConfig = new Config();
  
  /**
   Maps {@link Action} classes to a List of expected {@link hirondelle.web4j.request.RequestParameter} objects.
   If the incoming request contains a request parameter whose name or value is not consistent with this 
   list, then a {@link hirondelle.web4j.model.BadRequestException} is thrown.
   
   <P>Key - class object
   <br>Value - Set of RequestParameter objects; may be empty, but not null.
   
   <P>This is a mutable object field, but is not modified after startup, so this class is thread-safe.
  */
  private static Map<Class<Action>, Set<RequestParameter>> fExpectedParams = new LinkedHashMap<Class<Action>, Set<RequestParameter>>();

  /** Special, 'internal' request parameter, used by the framework to defend against CSRF attacks.  */
  private static final RequestParameter fCSRF_REQ_PARAM = RequestParameter.withLengthCheck(CsrfFilter.FORM_SOURCE_ID_KEY);
  
  private static final String CURRENT_TOKEN_CSRF = CsrfFilter.FORM_SOURCE_ID_KEY;
  private static final String PREVIOUS_TOKEN_CSRF = CsrfFilter.PREVIOUS_FORM_SOURCE_ID_KEY;
  private static final Logger fLogger = Util.getLogger(ApplicationFirewallImpl.class);
  
  /**
   Some denial-of-service attacks place large amounts of data in the request 
   params, in an attempt to overload the server. This method will check for 
   such requests. This check must be performed first, before any further 
   processing is attempted.
  */
  private void checkForExtremeSize(RequestParser aRequest) throws BadRequestException {
    fLogger.fine("Checking for extreme size.");
    if ( isRequestExcessivelyLarge(aRequest) ) {
      throw new BadRequestException(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
    }
  }

  private boolean isRequestExcessivelyLarge(RequestParser aRequestParser){
    boolean result = false;
    if ( aRequestParser.isFileUploadRequest() ) {
      result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxFileUploadRequestSize(); 
    }
    else {
      result = aRequestParser.getRequest().getContentLength() > fConfig.getMaxHttpRequestSize(); 
    }
    return result;
  }
   
  void checkParamNamesAndValues(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    if ( fExpectedParams.containsKey(aAction.getClass()) ){
      Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
      //this method may return file upload controls - depends on interpretation, whether to include file upload controls in this method
      Enumeration paramNames = aRequestParser.getRequest().getParameterNames();
      while ( paramNames.hasMoreElements() ){
        String incomingParamName = (String)paramNames.nextElement();
        fLogger.fine("Checking parameter named " + Util.quote(incomingParamName));
        RequestParameter knownParam = matchToKnownParam(incomingParamName, expectedParams);
        if( knownParam == null ){
          fLogger.severe("*** Unknown Parameter *** : " + Util.quote(incomingParamName) + ". Please add public static final RequestParameter field for this item to your Action.");
          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
        }
        if ( knownParam.isFileUploadParameter() ) {
          fLogger.fine("File Upload parameter - value not validatable here: " + knownParam.getName());
          continue; //prevents checks on values for file upload controls
        }
        Collection<SafeText> paramValues = aRequestParser.toSafeTexts(knownParam);
        if( ! isInternalParam( knownParam) ) {
          checkParamValues(knownParam, paramValues);
        }
      }
    }
    else {
      String message = "Action " + aAction.getClass() + " not known to ApplicationFirewallImpl.";
      fLogger.severe(message);
      //this is NOT a BadRequestEx, since not outside the control of this framework.
      throw new RuntimeException(message);
    }
  }
  
  /** If no match is found, return <tt>null</tt>.   Matches to both regular and 'internal' request params. */
  private RequestParameter matchToKnownParam(String aIncomingParamName, Collection<RequestParameter> aExpectedParams){
    RequestParameter result = null;
    for (RequestParameter reqParam: aExpectedParams){
      if ( reqParam.getName().equals(aIncomingParamName) ){
        result = reqParam;
        break;
      }
    }
    if( result == null && fCSRF_REQ_PARAM.getName().equals(aIncomingParamName) ){
      result = fCSRF_REQ_PARAM;
    }
    return result;
  }
  
  private void checkParamValues(RequestParameter aKnownReqParam, Collection<SafeText> aParamValues) throws BadRequestException {
    for(SafeText paramValue: aParamValues){
      if ( Util.textHasContent(paramValue) ) {
        if ( ! aKnownReqParam.isValidParamValue(paramValue.getRawString()) ) {
          fLogger.severe("Request parameter named " + aKnownReqParam.getName() + " has an invalid value. Its size is: " + paramValue.getRawString().length());
          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
        }
        if( fConfig.getSpamDetectionInFirewall() ){
          SpamDetector spamDetector = BuildImpl.forSpamDetector();
          if( spamDetector.isSpam(paramValue.getRawString()) ){
            fLogger.fine("SPAM detected.");
            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
          }
        }
      }
    }
  }
  
  private void checkSideEffectOperations(Action aAction, RequestParser aRequestParser) throws BadRequestException {
    fLogger.fine("Checking for side-effect operations.");
    Set<RequestParameter> expectedParams = fExpectedParams.get(aAction.getClass());
    for (RequestParameter reqParam : expectedParams){
      if ( "Operation".equals(reqParam.getName()) ){
        String rawValue = aRequestParser.getRawParamValue(reqParam);
        if (Util.textHasContent(rawValue)){
          Operation operation = Operation.valueOf(rawValue);
          if ( isAttemptingSideEffectOperationWithoutPOST(operation, aRequestParser) ){
            fLogger.severe("Security problem. Attempted operation having side effects outside of a POST. Please use a <FORM> with method='POST'.");
            throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
          }
        }
      }
    }
  }
  
  private boolean isAttemptingSideEffectOperationWithoutPOST(Operation aOperation, RequestParser aRequestParser){
    return aOperation.hasSideEffects() && !aRequestParser.getRequest().getMethod().equals("POST");
  }

  /**
   An internal request param is not declared explicitly by the application programmer. Rather, it is defined and 
   used only by the framework.
  */
  private boolean  isInternalParam(RequestParameter aRequestParam) {
    return aRequestParam.getName().equals(fCSRF_REQ_PARAM.getName());
  }

  private void defendAgainstCSRFAttacks(RequestParser aRequestParser) throws BadRequestException {
    if( requestNeedsDefendingAgainstCSRFAttacks(aRequestParser) ) {
      Id postedTokenValue = aRequestParser.toId(fCSRF_REQ_PARAM);
      if ( FAILS == toIncludeCsrfTokenWithForm(postedTokenValue) ){
        fLogger.severe("CSRF token not included in POSTed request. Rejecting this request, since it is likely an attack.");
        throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);
      }
      
      if( FAILS == matchCurrentCSRFToken(aRequestParser, postedTokenValue) ) {
        if( FAILS == matchPreviousCSRFToken(aRequestParser, postedTokenValue) ) {
          fLogger.severe("CSRF token does not match the expected value. Rejecting this request, since it is likely an attack.");
          throw new BadRequestException(HttpServletResponse.SC_BAD_REQUEST);        
        }
      }
      fLogger.fine("Success: no CSRF problem detected.");
    }
  }
  
  private boolean requestNeedsDefendingAgainstCSRFAttacks(RequestParser aRequestParser){
    boolean isPOST =  aRequestParser.getRequest().getMethod().equalsIgnoreCase("POST");
    boolean sessionPresent = isSessionPresent(aRequestParser);
    boolean csrfFilterIsTurnedOn = false;
    if( sessionPresent ) {
      Id csrfTokenInSession = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
      csrfFilterIsTurnedOn = (csrfTokenInSession != null);
    }
    
    if( isPOST &&  sessionPresent && ! csrfFilterIsTurnedOn )  {
      fLogger.warning("POST operation, but no CSRF form token present in existing session. This application does not have WEB4J defenses against CSRF attacks configured in the recommended way.");  
    }
    
    boolean result =  isPOST && sessionPresent && csrfFilterIsTurnedOn;
    fLogger.fine("Session exists, and the CsrfFilter is turned on : " + csrfFilterIsTurnedOn);
    fLogger.fine("Does the firewall need to check this request for CSRF attacks? : " + result);
    return result;
  }
  
  private boolean toIncludeCsrfTokenWithForm(Id aCsrfToken){
    return aCsrfToken != null;
  }
  
  private boolean matchCurrentCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue) {
    Id currentToken = getCsrfTokenInSession(CURRENT_TOKEN_CSRF, aRequestParser);
    return aPostedTokenValue.equals(currentToken);
  }
  
  private boolean matchPreviousCSRFToken(RequestParser aRequestParser, Id aPostedTokenValue){
    //in the case of an anonymous session, with no login, this item will be null
    Id previousToken = getCsrfTokenInSession(PREVIOUS_TOKEN_CSRF, aRequestParser);
    return aPostedTokenValue.equals(previousToken);
  }

  private boolean isSessionPresent(RequestParser aRequestParser){
    boolean DO_NOT_CREATE = false;
    HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
    return session != null;
  }
  
  /** Only called when session is present. No risk of null pointer exception. */
  private Id getCsrfTokenInSession(String aKey, RequestParser aRequestParser){
    boolean DO_NOT_CREATE = false;
    HttpSession session = aRequestParser.getRequest().getSession(DO_NOT_CREATE);
    return (Id)session.getAttribute(aKey);
  }
}
