/*
 * Copyright (C) 2000 - 2026 Silverpeas
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Affero General Public License as
 * published by the Free Software Foundation, either version 3 of the
 * License, or (at your option) any later version.
 *
 * As a special exception to the terms and conditions of version 3.0 of
 * the GPL, you may redistribute this Program in connection with Free/Libre
 * Open Source Software ("FLOSS") applications as described in Silverpeas's
 * FLOSS exception.  You should have received a copy of the text describing
 * the FLOSS exception, and it is also available here:
 * "https://www.silverpeas.org/legal/floss_exception.html"
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU Affero General Public License for more details.
 *
 * You should have received a copy of the GNU Affero General Public License
 * along with this program.  If not, see <http://www.gnu.org/licenses/>.
 */

package org.silverpeas.sso.saml;

import jakarta.annotation.Nonnull;
import jakarta.security.auth.message.AuthException;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import net.shibboleth.shared.component.ComponentInitializationException;
import net.shibboleth.shared.httpclient.HttpClientBuilder;
import org.jetbrains.annotations.NotNull;
import org.opensaml.core.config.InitializationException;
import org.opensaml.core.config.InitializationService;
import org.opensaml.core.xml.XMLObject;
import org.opensaml.core.xml.config.XMLObjectProviderRegistrySupport;
import org.opensaml.core.xml.util.XMLObjectSupport;
import org.opensaml.messaging.context.InOutOperationContext;
import org.opensaml.messaging.context.MessageContext;
import org.opensaml.messaging.encoder.MessageEncodingException;
import org.opensaml.messaging.handler.MessageHandler;
import org.opensaml.messaging.handler.MessageHandlerException;
import org.opensaml.messaging.handler.impl.BasicMessageHandlerChain;
import org.opensaml.messaging.pipeline.httpclient.BasicHttpClientMessagePipeline;
import org.opensaml.messaging.pipeline.httpclient.HttpClientMessagePipeline;
import org.opensaml.profile.context.ProfileRequestContext;
import org.opensaml.saml.common.binding.security.impl.MessageLifetimeSecurityHandler;
import org.opensaml.saml.common.binding.security.impl.ReceivedEndpointSecurityHandler;
import org.opensaml.saml.common.binding.security.impl.SAMLOutboundProtocolMessageSigningHandler;
import org.opensaml.saml.common.messaging.context.SAMLEndpointContext;
import org.opensaml.saml.common.messaging.context.SAMLMessageInfoContext;
import org.opensaml.saml.common.messaging.context.SAMLPeerEntityContext;
import org.opensaml.saml.common.xml.SAMLConstants;
import org.opensaml.saml.saml2.binding.decoding.impl.HttpClientResponseSOAP11Decoder;
import org.opensaml.saml.saml2.binding.encoding.impl.HTTPRedirectDeflateEncoder;
import org.opensaml.saml.saml2.binding.encoding.impl.HttpClientRequestSOAP11Encoder;
import org.opensaml.saml.saml2.core.*;
import org.opensaml.saml.saml2.encryption.Decrypter;
import org.opensaml.saml.saml2.metadata.Endpoint;
import org.opensaml.saml.saml2.metadata.SingleSignOnService;
import org.opensaml.saml.security.impl.SAMLSignatureProfileValidator;
import org.opensaml.soap.client.http.AbstractPipelineHttpSOAPClient;
import org.opensaml.xmlsec.SignatureSigningParameters;
import org.opensaml.xmlsec.config.impl.JavaCryptoValidationInitializer;
import org.opensaml.xmlsec.context.SecurityParametersContext;
import org.opensaml.xmlsec.encryption.support.DecryptionException;
import org.opensaml.xmlsec.encryption.support.InlineEncryptedKeyResolver;
import org.opensaml.xmlsec.signature.Signature;
import org.opensaml.xmlsec.signature.support.SignatureConstants;
import org.opensaml.xmlsec.signature.support.SignatureException;
import org.opensaml.xmlsec.signature.support.SignatureValidator;
import org.silverpeas.kernel.SilverpeasRuntimeException;
import org.silverpeas.kernel.util.StringUtil;

import java.io.IOException;
import java.io.StringReader;
import java.security.Provider;
import java.security.Security;
import java.time.Duration;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.Objects;
import java.util.Optional;

import static java.text.MessageFormat.format;
import static org.silverpeas.kernel.util.StringUtil.*;
import static org.silverpeas.sso.saml.OpenSamlUtils.buildSamlObject;
import static org.silverpeas.sso.saml.OpenSamlUtils.logSamlObject;
import static org.silverpeas.sso.saml.SamlAuthHelper.invalidateAuth;
import static org.silverpeas.sso.saml.SamlAuthHelper.setSessionPrincipal;
import static org.silverpeas.sso.saml.SamlLogger.getLogSessionId;
import static org.silverpeas.sso.saml.SamlLogger.logger;
import static org.silverpeas.sso.saml.settings.SamlSettings.*;

/**
 * @author silveryocha
 */
public class SamlFilter implements Filter {

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
      throws ServletException, IOException {
    if (request instanceof HttpServletRequest httpRequest &&
        response instanceof HttpServletResponse httpResponse) {
      try {
        // check if user has a AuthData in the session
        if (!SamlAuthHelper.isAuthenticated(httpRequest)) {
          final SamlContext context = new SamlContext(httpRequest, httpResponse);
          if (redirectUserForAuthentication(context)) {
            return;
          }
          performSamlArtifactResolve(context);
          performSamlRequest(context);
          performSamlResponse(context);
        }
      } catch (AuthException | SignatureException fe) {
        // something went wrong (like expiration or revocation of token)
        // we should invalidate AuthData stored in session and redirect to Authorization server
        invalidateAuth(httpRequest);
        logger().debug(() -> format(
            "Due to authentication error, going to Saml SSO URL server for session {0}.",
            getLogSessionId(httpRequest)));
      } catch (Exception e) {
        logger().error(e);
        httpResponse.setStatus(500);
      }
    }
    chain.doFilter(request, response);
  }

  private void performSamlArtifactResolve(final SamlContext context)
      throws AuthException, SignatureException {
    final Optional<String> optionalArtifactResolve = context.getArtifactResolve();
    if (optionalArtifactResolve.isPresent()) {
      if (isNotDefined(getArtifactResolutionServiceUrl(context.getHttpRequest()))) {
        throw new SilverpeasRuntimeException(
            format("URL of artifact resolution service is not defined for domain {0}",
                getSilverpeasDomainId(context.getHttpRequest())));
      }
      logger().debug("Artifact received");
      final Artifact artifact = buildArtifactFromRequest(optionalArtifactResolve.get());
      logger().debug("Artifact: " + artifact.getValue());
      final ArtifactResolve artifactResolve = buildArtifactResolve(context, artifact);
      logger().debug("Sending ArtifactResolve");
      logger().debug("ArtifactResolve: ");
      logSamlObject(artifactResolve);
      ArtifactResponse artifactResponse = sendAndReceiveArtifactResolve(context, artifactResolve);
      logger().debug("ArtifactResponse received");
      logger().debug("ArtifactResponse: ");
      logSamlObject(artifactResponse);
      validateDestinationAndLifetime(context, artifactResponse);
      EncryptedAssertion encryptedAssertion = getEncryptedAssertion(artifactResponse);
      Assertion assertion = decryptAssertion(encryptedAssertion);
      validateAssertion(context, assertion);
      logger().debug("Decrypted Assertion: ");
      logSamlObject(assertion);
    }
  }

  private Artifact buildArtifactFromRequest(final String artifactResolve) {
    Artifact artifact = buildSamlObject(Artifact.class);
    artifact.setValue(artifactResolve);
    return artifact;
  }

  private ArtifactResolve buildArtifactResolve(final SamlContext context, final Artifact artifact) {
    final ArtifactResolve artifactResolve = buildSamlObject(ArtifactResolve.class);
    Issuer issuer = buildSamlObject(Issuer.class);
    issuer.setValue(getEntityId(context.getHttpRequest()));
    artifactResolve.setIssuer(issuer);
    artifactResolve.setIssueInstant(Instant.now());
    artifactResolve.setID(OpenSamlUtils.generateSecureRandomId());
    artifactResolve.setDestination(getArtifactResolutionServiceUrl(context.getHttpRequest()));
    artifactResolve.setArtifact(artifact);
    return artifactResolve;
  }

  private ArtifactResponse sendAndReceiveArtifactResolve(final SamlContext context,
      final ArtifactResolve artifactResolve) {
    try {
      final MessageContext contextOut = new MessageContext();
      contextOut.setMessage(artifactResolve);
      signingOptionallyWithSP(context, contextOut);
      final InOutOperationContext opContext = new ProfileRequestContext();
      opContext.setOutboundMessageContext(contextOut);
      final AbstractPipelineHttpSOAPClient soapClient = getPipelineHttpSOAPClient();
      soapClient.send(getArtifactResolutionServiceUrl(context.getHttpRequest()), opContext);
      return (ArtifactResponse) Objects.requireNonNull(
          opContext.getInboundMessageContext()).getMessage();
    } catch (Exception e) {
      throw new SilverpeasRuntimeException(e);
    }
  }

  @NotNull
  private static AbstractPipelineHttpSOAPClient getPipelineHttpSOAPClient() throws Exception {
    final AbstractPipelineHttpSOAPClient soapClient = new AbstractPipelineHttpSOAPClient() {
      @Override
      @Nonnull
      protected HttpClientMessagePipeline newPipeline() {
        final HttpClientRequestSOAP11Encoder encoder = new HttpClientRequestSOAP11Encoder();
        final HttpClientResponseSOAP11Decoder decoder = new HttpClientResponseSOAP11Decoder();
        final BasicHttpClientMessagePipeline pipeline = new BasicHttpClientMessagePipeline(
            encoder, decoder);
        pipeline.setOutboundPayloadHandler(new SAMLOutboundProtocolMessageSigningHandler());
        return pipeline;
      }
    };
    final HttpClientBuilder clientBuilder = new HttpClientBuilder();
    soapClient.setHttpClient(clientBuilder.buildClient());
    return soapClient;
  }

  @SuppressWarnings({"ConstantConditions"})
  private void validateDestinationAndLifetime(final SamlContext context,
      final ArtifactResponse artifactResponse) {
    final MessageContext msgContext = new MessageContext();
    msgContext.setMessage(artifactResponse);
    final SAMLMessageInfoContext messageInfoContext = msgContext
        .ensureSubcontext(SAMLMessageInfoContext.class);
    messageInfoContext.setMessageIssueInstant(artifactResponse.getIssueInstant());
    final MessageLifetimeSecurityHandler lifetimeSecurityHandler = new
        MessageLifetimeSecurityHandler();
    lifetimeSecurityHandler.setClockSkew(Duration.ofMillis(1000));
    lifetimeSecurityHandler.setMessageLifetime(Duration.ofMillis(2000));
    lifetimeSecurityHandler.setRequiredRule(true);
    final ReceivedEndpointSecurityHandler receivedEndpointSecurityHandler = new
        ReceivedEndpointSecurityHandler();
    receivedEndpointSecurityHandler.setHttpServletRequestSupplier(context::getHttpRequest);
    final List<MessageHandler> handlers = new ArrayList<>();
    handlers.add(lifetimeSecurityHandler);
    handlers.add(receivedEndpointSecurityHandler);
    final BasicMessageHandlerChain handlerChain = new BasicMessageHandlerChain();
    handlerChain.setHandlers(handlers);
    try {
      handlerChain.initialize();
      handlerChain.invoke(msgContext);
    } catch (ComponentInitializationException | MessageHandlerException e) {
      throw new SilverpeasRuntimeException(e);
    }
  }

  private EncryptedAssertion getEncryptedAssertion(final ArtifactResponse artifactResponse) {
    Response response = (Response) artifactResponse.getMessage();
    return Objects.requireNonNull(response).getEncryptedAssertions().get(0);
  }

  private Assertion decryptAssertion(final EncryptedAssertion encryptedAssertion) {
    final Decrypter decrypter = new Decrypter(null, null, new InlineEncryptedKeyResolver());
    decrypter.setRootInNewDocument(true);
    try {
      return decrypter.decrypt(encryptedAssertion);
    } catch (DecryptionException e) {
      throw new SilverpeasRuntimeException(e);
    }
  }

  private void performSamlRequest(final SamlContext context) {
    if (context.getSamlRequest().isPresent()) {
      logger().debug(() -> format("Processing SAML request for session {0}.",
          getLogSessionId(context.getHttpRequest())));
      logger().debug(
          () -> format("Nothing done for now {0}.", getLogSessionId(context.getHttpRequest())));
    }
  }

  private void performSamlResponse(final SamlContext context)
      throws AuthException, SignatureException {
    final Optional<String> samlResponse = context.getSamlResponse();
    if (samlResponse.isPresent()) {
      final Assertion assertion = getSamlAssertion(samlResponse.get());
      validateAssertion(context, assertion);
    }
  }

  private boolean redirectUserForAuthentication(final SamlContext context) {
    boolean performed = false;
    if (context.getArtifactResolve().isEmpty() && context.getSamlRequest().isEmpty() &&
        context.getSamlResponse().isEmpty()) {
      // not authenticated
      logger().debug(() -> format("Going to saml SSO URL for session {0}.",
          getLogSessionId(context.getHttpRequest())));
      final AuthnRequest authnRequest = buildAuthnRequest(context);
      redirectUserWithRequest(context, authnRequest);
      performed = true;
    }
    return performed;
  }

  private AuthnRequest buildAuthnRequest(final SamlContext context) {
    final AuthnRequest authnRequest = buildSamlObject(AuthnRequest.class);
    authnRequest.setIssueInstant(Instant.now());
    authnRequest.setDestination(getSsoServiceUrl(context.getHttpRequest()));
    authnRequest.setProtocolBinding(SAMLConstants.SAML2_POST_BINDING_URI);
    authnRequest.setAssertionConsumerServiceURL(getAssertionConsumerServiceUrl(context.getHttpRequest()));
    authnRequest.setID(OpenSamlUtils.generateSecureRandomId());
    authnRequest.setIssuer(buildIssuer(context));
    authnRequest.setNameIDPolicy(buildNameIdPolicy());
    authnRequest.setRequestedAuthnContext(buildRequestedAuthnContext(context));
    return authnRequest;
  }

  private Issuer buildIssuer(final SamlContext context) {
    final Issuer issuer = buildSamlObject(Issuer.class);
    issuer.setValue(getEntityId(context.getHttpRequest()));
    return issuer;
  }

  private NameIDPolicy buildNameIdPolicy() {
    final NameIDPolicy nameIDPolicy = buildSamlObject(NameIDPolicy.class);
    nameIDPolicy.setAllowCreate(false);
    nameIDPolicy.setFormat(NameIDType.UNSPECIFIED);
    return nameIDPolicy;
  }

  private RequestedAuthnContext buildRequestedAuthnContext(final SamlContext context) {
    final RequestedAuthnContext requestedAuthnContext =
        buildSamlObject(RequestedAuthnContext.class);
    requestedAuthnContext.setComparison(getAuthnContextComparison(context.getHttpRequest()));
    final AuthnContextClassRef authnContextClassRef = buildSamlObject(AuthnContextClassRef.class);
    authnContextClassRef.setURI(getAuthnContextClass(context.getHttpRequest()));
    requestedAuthnContext.getAuthnContextClassRefs().add(authnContextClassRef);
    return requestedAuthnContext;
  }

  @SuppressWarnings({"ConstantConditions"})
  private void redirectUserWithRequest(final SamlContext context, final AuthnRequest authnRequest) {
    final MessageContext msgContext = new MessageContext();
    msgContext.setMessage(authnRequest);
    final SAMLPeerEntityContext peerEntityContext =
        msgContext.ensureSubcontext(SAMLPeerEntityContext.class);
    final SAMLEndpointContext endpointContext =
        peerEntityContext.ensureSubcontext(SAMLEndpointContext.class);
    endpointContext.setEndpoint(getIdpEndpoint(context));
    signingOptionallyWithSP(context, msgContext);
    final HTTPRedirectDeflateEncoder encoder = new HTTPRedirectDeflateEncoder();
    encoder.setMessageContext(msgContext);
    encoder.setHttpServletResponseSupplier(context::getHttpResponse);
    try {
      encoder.initialize();
    } catch (ComponentInitializationException e) {
      throw new SilverpeasRuntimeException(e);
    }
    logger().debug("AuthnRequest: ");
    logSamlObject(authnRequest);
    logger().info("Redirecting to IDP");
    try {
      encoder.encode();
    } catch (MessageEncodingException e) {
      throw new SilverpeasRuntimeException(e);
    }
  }

  private Endpoint getIdpEndpoint(final SamlContext context) {
    SingleSignOnService endpoint = buildSamlObject(SingleSignOnService.class);
    endpoint.setBinding(SAMLConstants.SAML2_REDIRECT_BINDING_URI);
    endpoint.setLocation(getSsoServiceUrl(context.getHttpRequest()));
    return endpoint;
  }

  @SuppressWarnings("ConstantConditions")
  private void validateSignature(final SamlContext context, final Assertion assertion)
      throws SignatureException {
    if (!assertion.isSigned()) {
      throw new SignatureException("The SAML Assertion was not signed");
    }
    final Signature signature = assertion.getSignature();
    final SAMLSignatureProfileValidator profileValidator = new SAMLSignatureProfileValidator();
    profileValidator.validate(signature);
    SignatureValidator.validate(signature, getIdpCredential(context.getHttpRequest()));
  }

  private void validateAssertion(final SamlContext context, final Assertion assertion)
      throws SignatureException, AuthException {
    logger().debug(() -> format("Processing SAML response for session {0}.",
        getLogSessionId(context.getHttpRequest())));
    validateSignature(context, assertion);
    validateDelay(context, assertion);
    validatePrincipal(context, assertion);
  }

  private void validateDelay(final SamlContext context, final Assertion assertion)
      throws AuthException {
    if (isNotBeforeAssertionConditionEnabled(context.httpRequest)) {
      final Instant notBefore = Objects.requireNonNull(assertion.getConditions()).getNotBefore();
      if (notBefore == null) {
        throw new AuthException("'not before' condition is missing");
      } else if (notBefore.isAfter(Instant.now())) {
        throw new AuthException("authentication is not yet possible");
      }
    }
    if (isNotOnOrAfterAssertionConditionEnabled(context.httpRequest)) {
      final Conditions conditions = assertion.getConditions();
      if (conditions == null) {
        throw new AuthException("No conditions found!");
      }
      final Instant notOnOrAfter =assertion.getConditions().getNotOnOrAfter();
      if (notOnOrAfter == null) {
        throw new AuthException("'not on or after' condition is missing");
      } else if (!notOnOrAfter.isAfter(Instant.now())) {
        throw new AuthException("authentication delay expired");
      }
    }
  }

  private void validatePrincipal(final SamlContext context, final Assertion assertion)
      throws AuthException {
    final String principal = Optional.ofNullable(assertion)
        .map(Assertion::getSubject)
        .map(s -> {
          OpenSamlUtils.logSamlObject(s);
          return s.getNameID();
        })
        .map(NameIDType::getValue)
        .filter(StringUtil::isDefined)
        .orElseThrow(() -> new AuthException("no principal detected"));
    setSessionPrincipal(context.getHttpRequest(), principal);
  }

  @SuppressWarnings("ConstantConditions")
  private Assertion getSamlAssertion(final String samlAsXml) throws AuthException {
    try {
      final XMLObject xmlObj = XMLObjectSupport
          .unmarshallFromReader(XMLObjectProviderRegistrySupport.getParserPool(),
              new StringReader(samlAsXml));
      final Response response = (Response) xmlObj;
      return response.getAssertions().stream().findFirst().orElseThrow(() -> {
        final Optional<Status> status = Optional.ofNullable(response.getStatus());
        return new AuthException(format("SAML response status code {0} with message -> {1}",
            status.map(Status::getStatusCode).map(StatusCode::getValue).orElse("N/A"),
            status.map(Status::getStatusMessage).map(StatusMessage::getValue).orElse("N/A")));
      });
    } catch (Exception e) {
      logger().error(e);
      throw new AuthException(e.getMessage());
    }
  }

  private void signingOptionallyWithSP(final SamlContext context, final MessageContext msgContext) {
    getSpCredential(context.httpRequest).ifPresent(c -> {
      final SignatureSigningParameters signatureSigningParameters =
          new SignatureSigningParameters();
      signatureSigningParameters.setSigningCredential(c);
      signatureSigningParameters.setSignatureAlgorithm(SignatureConstants.ALGO_ID_SIGNATURE_RSA_SHA256);
      signatureSigningParameters.setSignatureCanonicalizationAlgorithm(SignatureConstants.ALGO_ID_C14N_EXCL_OMIT_COMMENTS);
      msgContext.ensureSubcontext(SecurityParametersContext.class).setSignatureSigningParameters(signatureSigningParameters);
    });
  }

  @Override
  public void init(final FilterConfig filterConfig) throws ServletException {
    try {
      final JavaCryptoValidationInitializer javaCryptoValidationInitializer = new
          JavaCryptoValidationInitializer();
      javaCryptoValidationInitializer.init();
    } catch (InitializationException e) {
      throw new ServletException(e);
    }
    logger().info("Security providers:");
    for (Provider jceProvider : Security.getProviders()) {
      logger().info("\t{0}", jceProvider.getInfo());
    }
    try {
      InitializationService.initialize();
    } catch (Exception e) {
      logger().error(e);
      throw new ServletException(e);
    }
  }

  @Override
  public void destroy() {
    // nothing to do
  }

  private static class SamlContext {
    private final HttpServletRequest httpRequest;
    private final HttpServletResponse httpResponse;
    private String artifactResolve = null;
    private String samlRequest = null;
    private String samlResponse = null;

    SamlContext(final HttpServletRequest httpRequest, final HttpServletResponse httpResponse) {
      this.httpRequest = httpRequest;
      this.httpResponse = httpResponse;
      decodeParameters();
    }

    private void decodeParameters() {
      artifactResolve = defaultStringIfNotDefined(httpRequest.getParameter("SAMLart"), null);
      samlRequest = defaultStringIfNotDefined(httpRequest.getParameter("SAMLRequest"), null);
      samlResponse = defaultStringIfNotDefined(httpRequest.getParameter("SAMLResponse"), null);
    }

    HttpServletRequest getHttpRequest() {
      return httpRequest;
    }

    HttpServletResponse getHttpResponse() {
      return httpResponse;
    }

    Optional<String> getArtifactResolve() {
      return Optional.ofNullable(artifactResolve);
    }

    Optional<String> getSamlRequest() {
      return Optional.ofNullable(samlRequest);
    }

    Optional<String> getSamlResponse() {
      return Optional.ofNullable(samlResponse != null ? new String(fromBase64(samlResponse)) :
          null);
    }
  }
}
