/*
 * Licensed to the Apache Software Foundation (ASF) under one
 * or more contributor license agreements.  See the NOTICE file
 * distributed with this work for additional information
 * regarding copyright ownership.  The ASF licenses this file
 * to you under the Apache License, Version 2.0 (the
 * "License"); you may not use this file except in compliance
 * with the License.  You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.knox.gateway.provider.federation.jwt.filter;

import org.apache.commons.lang3.StringUtils;
import org.apache.knox.gateway.config.GatewayConfig;
import org.apache.knox.gateway.i18n.messages.MessagesFactory;
import org.apache.knox.gateway.provider.federation.jwt.JWTMessages;
import org.apache.knox.gateway.security.PrimaryPrincipal;
import org.apache.knox.gateway.services.security.token.impl.JWT;
import org.apache.knox.gateway.services.security.token.impl.JWTToken;
import org.apache.knox.gateway.util.CertificateUtils;
import org.apache.knox.gateway.util.CookieUtils;
import org.eclipse.jetty.http.MimeTypes;

import javax.security.auth.Subject;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.MalformedURLException;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.text.ParseException;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.StringTokenizer;

public class SSOCookieFederationFilter extends AbstractJWTFilter {
  private static final JWTMessages LOGGER = MessagesFactory.get( JWTMessages.class );

  public static final String XHR_HEADER = "X-Requested-With";
  public static final String XHR_VALUE = "XMLHttpRequest";

  private static final String GATEWAY_PATH = "gateway.path";
  public static final String SSO_COOKIE_NAME = "sso.cookie.name";
  public static final String SSO_EXPECTED_AUDIENCES = "sso.expected.audiences";
  public static final String SSO_AUTHENTICATION_PROVIDER_URL = "sso.authentication.provider.url";
  public static final String SSO_VERIFICATION_PEM = "sso.token.verification.pem";
  public static final String X_FORWARDED_HOST = "X-Forwarded-Host";
  public static final String X_FORWARDED_PORT = "X-Forwarded-Port";
  public static final String X_FORWARDED_PROTO = "X-Forwarded-Proto";

  private static final String ORIGINAL_URL_QUERY_PARAM = "originalUrl=";
  private static final String DEFAULT_SSO_COOKIE_NAME = "hadoop-jwt";

  /* A semicolon separated list of paths that need to bypass authentication */
  private static final String SSO_UNAUTHENTICATED_PATHS_PARAM = "sso.unauthenticated.path.list";
  private static final String DEFAULT_SSO_UNAUTHENTICATED_PATHS_PARAM = "favicon.ico";
  private String cookieName;
  private String authenticationProviderUrl;
  private String gatewayPath;
  private Set<String> unAuthenticatedPaths = new HashSet(20);

  @Override
  public void init( FilterConfig filterConfig ) throws ServletException {
    super.init(filterConfig);

    // configured cookieName
    cookieName = filterConfig.getInitParameter(SSO_COOKIE_NAME);
    if (cookieName == null) {
      cookieName = DEFAULT_SSO_COOKIE_NAME;
    }

    // expected audiences or null
    String expectedAudiences = filterConfig.getInitParameter(SSO_EXPECTED_AUDIENCES);
    if (expectedAudiences != null) {
      audiences = parseExpectedAudiences(expectedAudiences);
    }

    // url to SSO authentication provider
    authenticationProviderUrl = filterConfig.getInitParameter(SSO_AUTHENTICATION_PROVIDER_URL);
    if (authenticationProviderUrl == null) {
      LOGGER.missingAuthenticationProviderUrlConfiguration();
    }

    // token verification pem
    String verificationPEM = filterConfig.getInitParameter(SSO_VERIFICATION_PEM);
    // setup the public key of the token issuer for verification
    if (verificationPEM != null) {
      publicKey = CertificateUtils.parseRSAPublicKey(verificationPEM);
    }

    /* get unauthenticated paths list */
    String unAuthPathString = filterConfig.getInitParameter(SSO_UNAUTHENTICATED_PATHS_PARAM);
    /* if no list specified use default value */
    if (StringUtils.isBlank(unAuthPathString)) {
      unAuthPathString = DEFAULT_SSO_UNAUTHENTICATED_PATHS_PARAM;
    }

    final StringTokenizer st = new StringTokenizer(unAuthPathString, ";,");
    while (st.hasMoreTokens()) {
      unAuthenticatedPaths.add(st.nextToken());
    }

    // gateway path for deriving an idp url when missing
    setGatewayPath(filterConfig);

    configureExpectedParameters(filterConfig);
  }

  private void setGatewayPath(FilterConfig filterConfig) {
    gatewayPath = filterConfig.getInitParameter(GATEWAY_PATH);
    if (gatewayPath == null || gatewayPath.isEmpty()) {
      final GatewayConfig gatewayConfig = filterConfig.getServletContext() == null ? null
          : (GatewayConfig) filterConfig.getServletContext().getAttribute(GatewayConfig.GATEWAY_CONFIG_ATTRIBUTE);
      if (gatewayConfig != null) {
        gatewayPath = gatewayConfig.getGatewayPath();
      }
    }
  }

  @Override
  public void destroy() {
  }

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws IOException, ServletException {
    HttpServletRequest req = (HttpServletRequest) request;
    HttpServletResponse res = (HttpServletResponse) response;

    List<Cookie> ssoCookies = CookieUtils.getCookiesForName(req, cookieName);
    if (ssoCookies.isEmpty()) {
      /* check for unauthenticated paths to bypass */
      for (final String path : unAuthenticatedPaths) {
        if (req.getRequestURI().contains(path)) {
          /* This path is configured as an unauthenticated path let the request through */
          final Subject sub = new Subject();
          sub.getPrincipals().add(new PrimaryPrincipal("anonymous"));
          LOGGER.unauthenticatedPathBypass(path, req.getRequestURI());
          continueWithEstablishedSecurityContext(sub, req, res, chain);
        }
      }

      if ("OPTIONS".equals(req.getMethod())) {
        // CORS preflight requests to determine allowed origins and related config
        // must be able to continue without being redirected
        Subject sub = new Subject();
        sub.getPrincipals().add(new PrimaryPrincipal("anonymous"));
        continueWithEstablishedSecurityContext(sub, req, res, chain);
      } else {
        sendRedirectToLoginURL(req, res);
      }
    } else {
      for(Cookie ssoCookie : ssoCookies) {
        String wireToken = ssoCookie.getValue();
        try {
          JWT token = new JWTToken(wireToken);
          if (validateToken(req, res, chain, token)) {
            Subject subject = createSubjectFromToken(token);
            continueWithEstablishedSecurityContext(subject, req, res, chain);

            // we found a valid cookie we don't need to keep checking anymore
            return;
          }
        } catch (ParseException ignore) {
          // Ignore the error since cookie was invalid
          // Fall through to keep checking if there are more cookies
        }
      }

      // There were no valid cookies found so redirect to login url
      if(res != null && !res.isCommitted()) {
        sendRedirectToLoginURL(req, res);
      }
    }
  }

  private void sendRedirectToLoginURL(HttpServletRequest request, HttpServletResponse response)
      throws IOException {
    String loginURL = constructLoginURL(request);
    LOGGER.sendRedirectToLoginURL(loginURL);
    response.sendRedirect(loginURL);
  }

  @Override
  protected void handleValidationError(HttpServletRequest request, HttpServletResponse response,
                                       int status, String error) throws IOException {
    /* We don't need redirect if this is a XHR request */
    if (request.getHeader(XHR_HEADER) != null &&
            request.getHeader(XHR_HEADER).equalsIgnoreCase(XHR_VALUE)) {
      response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      response.setContentType(MimeTypes.Type.TEXT_PLAIN.toString());
      if(error != null && !error.isEmpty()) {
        final byte[] data = error.getBytes(StandardCharsets.UTF_8);
        response.setContentLength(data.length);
        response.getOutputStream().write(data);
      }
    } else {
      String loginURL = constructLoginURL(request);
      response.sendRedirect(loginURL);
    }
  }

  /**
   * Create the URL to be used for authentication of the user in the absence of
   * a JWT token within the incoming request.
   *
   * @param request for getting the original request URL
   * @return url to use as login url for redirect
   */
  protected String constructLoginURL(HttpServletRequest request) {
    String providerURL = null;
    String delimiter = "?";
    if (authenticationProviderUrl == null) {
      providerURL = deriveDefaultAuthenticationProviderUrl(request);
    }
    else {
      providerURL = authenticationProviderUrl;
    }
    if (providerURL.contains("?")) {
      delimiter = "&";
    }
    return providerURL + delimiter
        + ORIGINAL_URL_QUERY_PARAM
        + request.getRequestURL().append(getOriginalQueryString(request));
  }

  /**
   * Derive a provider URL from the request assuming that the
   * KnoxSSO endpoint is local to the endpoint serving this request.
   * @param request origin request
   * @return url that is based on KnoxSSO endpoint
   */
  public String deriveDefaultAuthenticationProviderUrl(HttpServletRequest request) {
    String providerURL = null;
    String scheme;
    String host;
    int port;
    try {
      URL url = new URL(request.getRequestURL().toString());
      scheme = url.getProtocol();
      host = url.getHost();
      port = url.getPort();

      StringBuilder sb = new StringBuilder(scheme);
      sb.append("://").append(host);
      if (!host.contains(":") && port != -1) {
        sb.append(':').append(port);
      }
      sb.append('/').append(gatewayPath).append("/knoxsso/api/v1/websso");
      providerURL = sb.toString();
    } catch (MalformedURLException e) {
      LOGGER.failedToDeriveAuthenticationProviderUrl(e);
    }

    return providerURL;
  }

  private String getOriginalQueryString(HttpServletRequest request) {
    String originalQueryString = request.getQueryString();
    return (originalQueryString == null) ? "" : "?" + originalQueryString;
  }
}
