/*
 * Copyright 2004-present the original author or authors.
 *
 * Licensed 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
 *
 *      https://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.springframework.security.config.http;

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Iterator;
import java.util.List;
import java.util.function.Supplier;

import jakarta.servlet.Filter;
import jakarta.servlet.http.HttpServletRequest;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;

import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.TestingAuthenticationToken;
import org.springframework.security.authorization.AuthorizationManager;
import org.springframework.security.authorization.AuthorizationResult;
import org.springframework.security.core.Authentication;
import org.springframework.security.web.DefaultSecurityFilterChain;
import org.springframework.security.web.FilterChainProxy;
import org.springframework.security.web.FilterInvocation;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.UnreachableFilterChainException;
import org.springframework.security.web.access.AuthorizationManagerWebInvocationPrivilegeEvaluator;
import org.springframework.security.web.access.ExceptionTranslationFilter;
import org.springframework.security.web.access.PathPatternRequestTransformer;
import org.springframework.security.web.access.intercept.AuthorizationFilter;
import org.springframework.security.web.access.intercept.FilterInvocationSecurityMetadataSource;
import org.springframework.security.web.access.intercept.FilterSecurityInterceptor;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.context.SecurityContextPersistenceFilter;
import org.springframework.security.web.jaasapi.JaasApiIntegrationFilter;
import org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter;
import org.springframework.security.web.session.SessionManagementFilter;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
import org.springframework.util.ClassUtils;

public class DefaultFilterChainValidator implements FilterChainProxy.FilterChainValidator {

	private static final boolean USING_ACCESS = ClassUtils
		.isPresent("org.springframework.security.access.SecurityConfig", null);

	private static final Authentication TEST = new TestingAuthenticationToken("", "", Collections.emptyList());

	private final Log logger = LogFactory.getLog(getClass());

	private final AuthorizationManagerWebInvocationPrivilegeEvaluator.HttpServletRequestTransformer requestTransformer = new PathPatternRequestTransformer();

	@Override
	public void validate(FilterChainProxy fcp) {
		for (SecurityFilterChain filterChain : fcp.getFilterChains()) {
			checkLoginPageIsntProtected(fcp, filterChain.getFilters());
			checkFilterStack(filterChain.getFilters());
		}
		checkPathOrder(new ArrayList<>(fcp.getFilterChains()));
		checkForDuplicateMatchers(new ArrayList<>(fcp.getFilterChains()));
		checkAuthorizationFilters(new ArrayList<>(fcp.getFilterChains()));
	}

	private void checkPathOrder(List<SecurityFilterChain> filterChains) {
		// Check that the universal pattern is listed at the end, if at all
		Iterator<SecurityFilterChain> chains = filterChains.iterator();
		while (chains.hasNext()) {
			if (chains.next() instanceof DefaultSecurityFilterChain securityFilterChain) {
				if (AnyRequestMatcher.INSTANCE.equals(securityFilterChain.getRequestMatcher()) && chains.hasNext()) {
					throw new UnreachableFilterChainException("A universal match pattern ('/**') is defined "
							+ " before other patterns in the filter chain, causing them to be ignored. Please check the "
							+ "ordering in your <security:http> namespace or FilterChainProxy bean configuration",
							securityFilterChain, chains.next());
				}
			}
		}
	}

	private void checkForDuplicateMatchers(List<SecurityFilterChain> chains) {
		DefaultSecurityFilterChain filterChain = null;
		for (SecurityFilterChain chain : chains) {
			if (filterChain != null) {
				if (chain instanceof DefaultSecurityFilterChain defaultChain) {
					if (defaultChain.getRequestMatcher().equals(filterChain.getRequestMatcher())) {
						throw new UnreachableFilterChainException(
								"The FilterChainProxy contains two filter chains using the" + " matcher "
										+ defaultChain.getRequestMatcher()
										+ ". If you are using multiple <http> namespace "
										+ "elements, you must use a 'pattern' attribute to define the request patterns to which they apply.",
								defaultChain, chain);
					}
				}
			}
			if (chain instanceof DefaultSecurityFilterChain defaultChain) {
				filterChain = defaultChain;
			}
		}
	}

	private void checkAuthorizationFilters(List<SecurityFilterChain> chains) {
		Filter authorizationFilter = null;
		Filter filterSecurityInterceptor = null;
		for (SecurityFilterChain chain : chains) {
			for (Filter filter : chain.getFilters()) {
				if (filter instanceof AuthorizationFilter) {
					authorizationFilter = filter;
				}
				if (USING_ACCESS && AccessComponents.isFilterSecurityInterceptor(filter)) {
					filterSecurityInterceptor = filter;
				}
			}
			if (authorizationFilter != null && filterSecurityInterceptor != null) {
				this.logger.warn(
						"It is not recommended to use authorizeRequests or FilterSecurityInterceptor in the configuration. Please only use authorizeHttpRequests");
			}
			if (filterSecurityInterceptor != null) {
				this.logger.warn(
						"Usage of authorizeRequests and FilterSecurityInterceptor are deprecated. Please use authorizeHttpRequests in the configuration");
			}
			authorizationFilter = null;
			filterSecurityInterceptor = null;
		}
	}

	@SuppressWarnings({ "unchecked" })
	private static <F extends Filter> F getFilter(Class<F> type, List<Filter> filters) {
		for (Filter f : filters) {
			if (type.isAssignableFrom(f.getClass())) {
				return (F) f;
			}
		}
		return null;
	}

	/**
	 * Checks the filter list for possible errors and logs them
	 */
	private void checkFilterStack(List<Filter> filters) {
		checkForDuplicates(SecurityContextPersistenceFilter.class, filters);
		checkForDuplicates(UsernamePasswordAuthenticationFilter.class, filters);
		checkForDuplicates(SessionManagementFilter.class, filters);
		checkForDuplicates(BasicAuthenticationFilter.class, filters);
		checkForDuplicates(SecurityContextHolderAwareRequestFilter.class, filters);
		checkForDuplicates(JaasApiIntegrationFilter.class, filters);
		checkForDuplicates(ExceptionTranslationFilter.class, filters);
		if (USING_ACCESS) {
			checkForDuplicates(AccessComponents.getFilterSecurityInterceptorClass(), filters);
		}
		checkForDuplicates(AuthorizationFilter.class, filters);
	}

	private void checkForDuplicates(Class<? extends Filter> clazz, List<Filter> filters) {
		for (int i = 0; i < filters.size(); i++) {
			Filter f1 = filters.get(i);
			if (clazz.isAssignableFrom(f1.getClass())) {
				// Found the first one, check remaining for another
				for (int j = i + 1; j < filters.size(); j++) {
					Filter f2 = filters.get(j);
					if (clazz.isAssignableFrom(f2.getClass())) {
						this.logger.warn("Possible error: Filters at position " + i + " and " + j + " are both "
								+ "instances of " + clazz.getName());
						return;
					}
				}
			}
		}
	}

	/*
	 * Checks for the common error of having a login page URL protected by the security
	 * interceptor
	 */
	private void checkLoginPageIsntProtected(FilterChainProxy fcp, List<Filter> filterStack) {
		ExceptionTranslationFilter exceptions = getFilter(ExceptionTranslationFilter.class, filterStack);
		if (exceptions == null
				|| !(exceptions.getAuthenticationEntryPoint() instanceof LoginUrlAuthenticationEntryPoint)) {
			return;
		}
		String loginPage = ((LoginUrlAuthenticationEntryPoint) exceptions.getAuthenticationEntryPoint())
			.getLoginFormUrl();
		this.logger.info("Checking whether login URL '" + loginPage + "' is accessible with your configuration");
		FilterInvocation invocation = new FilterInvocation(loginPage, "POST");
		HttpServletRequest loginRequest = this.requestTransformer.transform(invocation.getRequest());
		List<Filter> filters = null;
		try {
			filters = fcp.getFilters(loginPage);
		}
		catch (Exception ex) {
			// May happen legitimately if a filter-chain request matcher requires more
			// request data than that provided
			// by the dummy request used when creating the filter invocation.
			this.logger.info("Failed to obtain filter chain information for the login page. Unable to complete check.");
		}
		if (filters == null || filters.isEmpty()) {
			this.logger.debug("Filter chain is empty for the login page");
			return;
		}
		if (getFilter(DefaultLoginPageGeneratingFilter.class, filters) != null) {
			this.logger.debug("Default generated login page is in use");
			return;
		}
		if (checkLoginPageIsPublic(filters, loginRequest)) {
			return;
		}
		AnonymousAuthenticationFilter anonymous = getFilter(AnonymousAuthenticationFilter.class, filters);
		if (anonymous == null) {
			this.logger.warn("The login page is being protected by the filter chain, but you don't appear to have"
					+ " anonymous authentication enabled. This is almost certainly an error.");
			return;
		}
		// Simulate an anonymous access with the supplied attributes.
		AnonymousAuthenticationToken token = new AnonymousAuthenticationToken("key", anonymous.getPrincipal(),
				anonymous.getAuthorities());
		Supplier<Boolean> check = deriveAnonymousCheck(filters, loginRequest, token);
		try {
			boolean allowed = check.get();
			if (!allowed) {
				this.logger.warn("Anonymous access to the login page doesn't appear to be enabled. "
						+ "This is almost certainly an error. Please check your configuration allows unauthenticated "
						+ "access to the configured login page. (Simulated access was rejected)");
			}
		}
		catch (Exception ex) {
			// May happen legitimately if a filter-chain request matcher requires more
			// request data than that provided
			// by the dummy request used when creating the filter invocation. See SEC-1878
			this.logger.info("Unable to check access to the login page to determine if anonymous access is allowed. "
					+ "This might be an error, but can happen under normal circumstances.", ex);
		}
	}

	private boolean checkLoginPageIsPublic(List<Filter> filters, HttpServletRequest loginRequest) {
		if (USING_ACCESS) {
			Boolean isPublic = AccessComponents.checkLoginPageIsPublic(filters, loginRequest);
			if (isPublic != null) {
				return isPublic;
			}
		}
		AuthorizationFilter authorizationFilter = getFilter(AuthorizationFilter.class, filters);
		if (authorizationFilter != null) {
			AuthorizationManager<HttpServletRequest> authorizationManager = authorizationFilter
				.getAuthorizationManager();
			try {
				AuthorizationResult result = authorizationManager.authorize(() -> TEST, loginRequest);
				return result != null && result.isGranted();
			}
			catch (Exception ex) {
				return false;
			}
		}
		return false;
	}

	private Supplier<Boolean> deriveAnonymousCheck(List<Filter> filters, HttpServletRequest loginRequest,
			AnonymousAuthenticationToken token) {
		if (USING_ACCESS) {
			Supplier<Boolean> check = AccessComponents.getAnonymousCheck(filters, loginRequest, token);
			if (check != null) {
				return check;
			}
		}
		AuthorizationFilter authorizationFilter = getFilter(AuthorizationFilter.class, filters);
		if (authorizationFilter != null) {
			return () -> {
				AuthorizationManager<HttpServletRequest> authorizationManager = authorizationFilter
					.getAuthorizationManager();
				AuthorizationResult result = authorizationManager.authorize(() -> token, loginRequest);
				return result != null && result.isGranted();
			};
		}
		return () -> true;
	}

	private static final class AccessComponents {

		private static final Log logger = LogFactory.getLog(DefaultFilterChainValidator.class);

		private static boolean isFilterSecurityInterceptor(Filter filter) {
			return filter instanceof FilterSecurityInterceptor;
		}

		private static Class<FilterSecurityInterceptor> getFilterSecurityInterceptorClass() {
			return FilterSecurityInterceptor.class;
		}

		private static Boolean checkLoginPageIsPublic(List<Filter> filters, HttpServletRequest loginRequest) {
			FilterSecurityInterceptor authorizationInterceptor = getFilter(FilterSecurityInterceptor.class, filters);
			if (authorizationInterceptor == null) {
				return null;
			}
			FilterInvocationSecurityMetadataSource fids = authorizationInterceptor.getSecurityMetadataSource();
			Collection<ConfigAttribute> attributes = fids.getAttributes(loginRequest);
			if (attributes == null) {
				logger.debug("No access attributes defined for login page URL");
				if (authorizationInterceptor.isRejectPublicInvocations()) {
					logger.warn("FilterSecurityInterceptor is configured to reject public invocations."
							+ " Your login page may not be accessible.");
				}
				return true;
			}
			return false;
		}

		private static Supplier<Boolean> getAnonymousCheck(List<Filter> filters, HttpServletRequest loginRequest,
				AnonymousAuthenticationToken token) {
			FilterSecurityInterceptor authorizationInterceptor = getFilter(FilterSecurityInterceptor.class, filters);
			if (authorizationInterceptor == null) {
				return null;
			}
			return () -> {
				FilterInvocationSecurityMetadataSource source = authorizationInterceptor.getSecurityMetadataSource();
				Collection<ConfigAttribute> attributes = source.getAttributes(loginRequest);
				try {
					authorizationInterceptor.getAccessDecisionManager().decide(token, loginRequest, attributes);
					return true;
				}
				catch (AccessDeniedException ex) {
					return false;
				}
			};
		}

	}

}
