package com.atlassian.stash.internal.web.auth;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;

import javax.servlet.*;
import java.io.IOException;

/**
 * Filter that allows plugins at the {@code before-login} position to integrate into the Spring Security filter chain,
 * without being part of the Spring Security filter chain.
 * <p>
 * This special filter is necessary because {@code before-login} filters MUST be applied before authentication is done
 * by the host application. One option would be to inject the {@code before-login} filters into the Spring Security
 * filter chain, but unfortunately this is not an option, because:
 * <ul>
 *     <li>
 *         The {@code before-login} filters MUST be applied before url rewriting occurs. If they do not, any filters
 *         that do request signature verification may fail. OAUTH is an example. Url rewriting happens before the
 *         Spring Security filter chain, which means that the filter order MUST be {@code before-login} - url rewrite -
 *         Spring Security.
 *     </li>
 *     <li>
 *         Some url patterns are excluded from Spring Security (security="none"). For these urls the Spring Security
 *         filter chain is not applied, but we still want {@code before-login} filters and url rewriting to apply to
 *         these requests. This means that the {@code before-login} and url rewrite filters cannot be integrated into
 *         the Spring Security filter chain.
 *     </li>
 * </ul>
 * At the start of the Spring Security filter chain, any existing {@link SecurityContext} is wiped out and replaced by
 * whatever the {@link org.springframework.security.web.context.SecurityContextRepository} provides. Unfortunately, this
 * wipes out any {@link Authentication} token that was provided by any of the {@code before-login} filters. This filter
 * preserves the token by:
 * <ul>
 *     <li>
 *         Registering a custom {@link SecurityContext} in the {@link SecurityContextHolder} <em>before</em> any of the
 *         before-login filters run. This custom {@link SecurityContext} copies any provided {@link Authentication}
 *         token to a {@link ThreadLocal} to be restored later on.
 *     </li>
 *     <li>
 *         When the Spring Security filter initializes, the existing {@link SecurityContext} is replaced by a fresh
 *         context, that may or may not be populated with an {@link Authentication} token from the users http session.
 *         After the {@link SecurityContext} is initialized, the {@link BeforeLoginPluginAuthenticationFilter} filter is
 *         applied again. At this point, if an {@link Authentication} token was provided by a {@code before-login}
 *         filter, that token is retored to the {@link SecurityContext}.
 *     </li>
 * </ul>
 *
 * @since 2.0
 */
public class BeforeLoginPluginAuthenticationFilter implements Filter {

    private static final Logger log = LoggerFactory.getLogger(BeforeLoginPluginAuthenticationFilter.class);
    private static final String BEFORE_LOGIN_APPLIED = "com.atlassian.stash.before.login.filter.applied";

    private final ThreadLocal<Authentication> pluginProvidedAuthentication = new ThreadLocal<>();

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {

        // This filter is called in two places - once before all the before-login filters are applied and once inside
        // the Spring security filter chain
        boolean filterApplied = request.getAttribute(BEFORE_LOGIN_APPLIED) != null;
        request.setAttribute(BEFORE_LOGIN_APPLIED, Boolean.TRUE);
        if (!filterApplied) {
            doBeforeBeforeLoginFilters(request, response, chain);
        } else {
            doInsideSpringSecurityChain(request, response, chain);
        }
    }

    private void doBeforeBeforeLoginFilters(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {

        SecurityContext originalContext = SecurityContextHolder.getContext();
        SecurityContextHolder.setContext(new DelegatingSecurityContext(
                originalContext != null ? originalContext : SecurityContextHolder.createEmptyContext()));

        try {
            chain.doFilter(request, response);
        } finally {
            // restore the original context and clear the threadLocal
            pluginProvidedAuthentication.remove();
            SecurityContextHolder.setContext(originalContext);
        }
    }

    private void doInsideSpringSecurityChain(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {

        Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
        boolean loginFromBeforeLoginPlugin =
                pluginProvidedAuthentication.get() != null && // some before-login plugin authenticated
                (authentication == null || !authentication.isAuthenticated()); // the current security context is not authenticated

        if (loginFromBeforeLoginPlugin) {
            log.debug("Restoring authentication token for {} that was provided by a before-login plugin",
                    pluginProvidedAuthentication.get().getName());
            SecurityContextHolder.getContext().setAuthentication(pluginProvidedAuthentication.get());
        }

        chain.doFilter(request, response);
    }

    @Override
    public void destroy() {
    }

    /**
     * Custom SecurityContext that copies any provided {@link Authentication} to a {@link ThreadLocal} to be restored
     * later.
     */
    private class DelegatingSecurityContext implements SecurityContext {

        private final SecurityContext securityContext;

        private DelegatingSecurityContext(SecurityContext securityContext) {
            this.securityContext = securityContext;
        }

        @Override
        public Authentication getAuthentication() {
            return securityContext.getAuthentication();
        }

        @Override
        public void setAuthentication(Authentication authentication) {
            securityContext.setAuthentication(authentication);
            pluginProvidedAuthentication.set(authentication);
        }
    }
}
