/*
 * Copyright 2012-2017 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
 *
 *      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.springframework.boot.actuate.autoconfigure;

import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;

import javax.annotation.PostConstruct;
import javax.servlet.http.HttpServletRequest;

import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.endpoint.Endpoint;
import org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMapping;
import org.springframework.boot.actuate.endpoint.mvc.MvcEndpoint;
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
import org.springframework.boot.autoconfigure.AutoConfigureBefore;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionMessage;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type;
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
import org.springframework.boot.autoconfigure.security.AuthenticationManagerConfiguration;
import org.springframework.boot.autoconfigure.security.FallbackWebSecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.security.IgnoredRequestCustomizer;
import org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration;
import org.springframework.boot.autoconfigure.security.SecurityPrerequisite;
import org.springframework.boot.autoconfigure.security.SecurityProperties;
import org.springframework.boot.autoconfigure.security.SpringBootWebSecurityConfiguration;
import org.springframework.boot.autoconfigure.web.ServerProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
import org.springframework.core.type.AnnotatedTypeMetadata;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.builders.WebSecurity.IgnoredRequestConfigurer;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfiguration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.annotation.web.configurers.ExpressionUrlAuthorizationConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.www.BasicAuthenticationEntryPoint;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import org.springframework.security.web.util.matcher.AnyRequestMatcher;
import org.springframework.security.web.util.matcher.NegatedRequestMatcher;
import org.springframework.security.web.util.matcher.OrRequestMatcher;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.StringUtils;

/**
 * {@link EnableAutoConfiguration Auto-configuration} for security of framework endpoints.
 * Many aspects of the behavior can be controller with {@link ManagementServerProperties}
 * via externalized application properties (or via an bean definition of that type to set
 * the defaults).
 * <p>
 * The framework {@link Endpoint}s (used to expose application information to operations)
 * include a {@link Endpoint#isSensitive() sensitive} configuration option which will be
 * used as a security hint by the filter created here.
 *
 * @author Dave Syer
 * @author Andy Wilkinson
 */
@Configuration
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnClass({ EnableWebSecurity.class })
@AutoConfigureAfter(SecurityAutoConfiguration.class)
@AutoConfigureBefore(FallbackWebSecurityAutoConfiguration.class)
@EnableConfigurationProperties(ManagementServerProperties.class)
public class ManagementWebSecurityAutoConfiguration {

	private static final String[] NO_PATHS = new String[0];

	private static final RequestMatcher MATCH_NONE = new NegatedRequestMatcher(
			AnyRequestMatcher.INSTANCE);

	@Bean
	public IgnoredRequestCustomizer managementIgnoredRequestCustomizer(
			ManagementServerProperties management,
			ObjectProvider<ManagementContextResolver> contextResolver) {
		return new ManagementIgnoredRequestCustomizer(management,
				contextResolver.getIfAvailable());
	}

	private class ManagementIgnoredRequestCustomizer implements IgnoredRequestCustomizer {

		private final ManagementServerProperties management;

		private final ManagementContextResolver contextResolver;

		ManagementIgnoredRequestCustomizer(ManagementServerProperties management,
				ManagementContextResolver contextResolver) {
			this.management = management;
			this.contextResolver = contextResolver;
		}

		@Override
		public void customize(IgnoredRequestConfigurer configurer) {
			if (!this.management.getSecurity().isEnabled()) {
				RequestMatcher requestMatcher = LazyEndpointPathRequestMatcher
						.getRequestMatcher(this.contextResolver);
				configurer.requestMatchers(requestMatcher);
			}

		}

	}

	@Configuration
	protected static class ManagementSecurityPropertiesConfiguration
			implements SecurityPrerequisite {

		private final SecurityProperties securityProperties;

		private final ManagementServerProperties managementServerProperties;

		public ManagementSecurityPropertiesConfiguration(
				ObjectProvider<SecurityProperties> securityProperties,
				ObjectProvider<ManagementServerProperties> managementServerProperties) {
			this.securityProperties = securityProperties.getIfAvailable();
			this.managementServerProperties = managementServerProperties.getIfAvailable();
		}

		@PostConstruct
		public void init() {
			if (this.managementServerProperties != null
					&& this.securityProperties != null) {
				this.securityProperties.getUser().getRole()
						.addAll(this.managementServerProperties.getSecurity().getRoles());
			}
		}

	}

	@Configuration
	@ConditionalOnMissingBean(WebSecurityConfiguration.class)
	@Conditional(WebSecurityEnablerCondition.class)
	@EnableWebSecurity
	protected static class WebSecurityEnabler extends AuthenticationManagerConfiguration {

	}

	/**
	 * WebSecurityEnabler condition.
	 */
	static class WebSecurityEnablerCondition extends SpringBootCondition {

		@Override
		public ConditionOutcome getMatchOutcome(ConditionContext context,
				AnnotatedTypeMetadata metadata) {
			String managementEnabled = context.getEnvironment()
					.getProperty("management.security.enabled", "true");
			String basicEnabled = context.getEnvironment()
					.getProperty("security.basic.enabled", "true");
			ConditionMessage.Builder message = ConditionMessage
					.forCondition("WebSecurityEnabled");
			if ("true".equalsIgnoreCase(managementEnabled)
					&& !"true".equalsIgnoreCase(basicEnabled)) {
				return ConditionOutcome.match(message.because("security enabled"));
			}
			return ConditionOutcome.noMatch(message.because("security disabled"));
		}

	}

	@Configuration
	@ConditionalOnMissingBean({ ManagementWebSecurityConfigurerAdapter.class })
	@ConditionalOnProperty(prefix = "management.security", name = "enabled", matchIfMissing = true)
	@EnableConfigurationProperties(SecurityProperties.class)
	@Order(ManagementServerProperties.BASIC_AUTH_ORDER)
	protected static class ManagementWebSecurityConfigurerAdapter
			extends WebSecurityConfigurerAdapter {

		private final SecurityProperties security;

		private final ManagementServerProperties management;

		private final ManagementContextResolver contextResolver;

		public ManagementWebSecurityConfigurerAdapter(SecurityProperties security,
				ManagementServerProperties management,
				ObjectProvider<ManagementContextResolver> contextResolver) {
			this.security = security;
			this.management = management;
			this.contextResolver = contextResolver.getIfAvailable();
		}

		@Override
		protected void configure(HttpSecurity http) throws Exception {
			// secure endpoints
			RequestMatcher matcher = getRequestMatcher();
			if (matcher != null) {
				// Always protect them if present
				if (this.security.isRequireSsl()) {
					http.requiresChannel().anyRequest().requiresSecure();
				}
				AuthenticationEntryPoint entryPoint = entryPoint();
				http.exceptionHandling().authenticationEntryPoint(entryPoint);
				// Match all the requests for actuator endpoints ...
				http.requestMatcher(matcher);
				// ... but permitAll() for the non-sensitive ones
				configurePermittedRequests(http.authorizeRequests());
				http.httpBasic().authenticationEntryPoint(entryPoint);
				// No cookies for management endpoints by default
				http.csrf().disable();
				http.sessionManagement()
						.sessionCreationPolicy(asSpringSecuritySessionCreationPolicy(
								this.management.getSecurity().getSessions()));
				SpringBootWebSecurityConfiguration.configureHeaders(http.headers(),
						this.security.getHeaders());
			}
		}

		private SessionCreationPolicy asSpringSecuritySessionCreationPolicy(
				Enum<?> value) {
			if (value == null) {
				return SessionCreationPolicy.STATELESS;
			}
			return SessionCreationPolicy.valueOf(value.name());
		}

		private RequestMatcher getRequestMatcher() {
			if (this.management.getSecurity().isEnabled()) {
				return LazyEndpointPathRequestMatcher
						.getRequestMatcher(this.contextResolver);
			}
			return null;
		}

		private AuthenticationEntryPoint entryPoint() {
			BasicAuthenticationEntryPoint entryPoint = new BasicAuthenticationEntryPoint();
			entryPoint.setRealmName(this.security.getBasic().getRealm());
			return entryPoint;
		}

		private void configurePermittedRequests(
				ExpressionUrlAuthorizationConfigurer<HttpSecurity>.ExpressionInterceptUrlRegistry requests) {
			requests.requestMatchers(new LazyEndpointPathRequestMatcher(
					this.contextResolver, EndpointPaths.SENSITIVE)).authenticated();
			// Permit access to the non-sensitive endpoints
			requests.requestMatchers(new LazyEndpointPathRequestMatcher(
					this.contextResolver, EndpointPaths.NON_SENSITIVE)).permitAll();
		}

	}

	private enum EndpointPaths {

		ALL,

		NON_SENSITIVE {

			@Override
			protected boolean isIncluded(MvcEndpoint endpoint) {
				return !endpoint.isSensitive();
			}

		},

		SENSITIVE {

			@Override
			protected boolean isIncluded(MvcEndpoint endpoint) {
				return endpoint.isSensitive();
			}

		};

		public String[] getPaths(EndpointHandlerMapping endpointHandlerMapping) {
			if (endpointHandlerMapping == null) {
				return NO_PATHS;
			}
			Set<? extends MvcEndpoint> endpoints = endpointHandlerMapping.getEndpoints();
			Set<String> paths = new LinkedHashSet<>(endpoints.size());
			for (MvcEndpoint endpoint : endpoints) {
				if (isIncluded(endpoint)) {
					String path = endpointHandlerMapping.getPath(endpoint.getPath());
					paths.add(path);
					if (!path.equals("")) {
						paths.add(path + "/**");
						// Add Spring MVC-generated additional paths
						paths.add(path + ".*");
					}
					paths.add(path + "/");
				}
			}
			return paths.toArray(new String[paths.size()]);
		}

		protected boolean isIncluded(MvcEndpoint endpoint) {
			return true;
		}

	}

	private static class LazyEndpointPathRequestMatcher implements RequestMatcher {

		private final EndpointPaths endpointPaths;

		private final ManagementContextResolver contextResolver;

		private RequestMatcher delegate;

		public static RequestMatcher getRequestMatcher(
				ManagementContextResolver contextResolver) {
			if (contextResolver == null) {
				return null;
			}
			ManagementServerProperties management = contextResolver
					.getApplicationContext().getBean(ManagementServerProperties.class);
			ServerProperties server = contextResolver.getApplicationContext()
					.getBean(ServerProperties.class);
			String path = management.getContextPath();
			if (StringUtils.hasText(path)) {
				AntPathRequestMatcher matcher = new AntPathRequestMatcher(
						server.getServlet().getPath(path) + "/**");
				return matcher;
			}
			// Match everything, including the sensitive and non-sensitive paths
			return new LazyEndpointPathRequestMatcher(contextResolver, EndpointPaths.ALL);
		}

		LazyEndpointPathRequestMatcher(ManagementContextResolver contextResolver,
				EndpointPaths endpointPaths) {
			this.contextResolver = contextResolver;
			this.endpointPaths = endpointPaths;
		}

		@Override
		public boolean matches(HttpServletRequest request) {
			if (this.delegate == null) {
				this.delegate = createDelegate();
			}
			return this.delegate.matches(request);
		}

		private RequestMatcher createDelegate() {
			ServerProperties server = this.contextResolver.getApplicationContext()
					.getBean(ServerProperties.class);
			List<RequestMatcher> matchers = new ArrayList<>();
			EndpointHandlerMapping endpointHandlerMapping = getRequiredEndpointHandlerMapping();
			for (String path : this.endpointPaths.getPaths(endpointHandlerMapping)) {
				matchers.add(
						new AntPathRequestMatcher(server.getServlet().getPath(path)));
			}
			return (matchers.isEmpty() ? MATCH_NONE : new OrRequestMatcher(matchers));
		}

		private EndpointHandlerMapping getRequiredEndpointHandlerMapping() {
			EndpointHandlerMapping endpointHandlerMapping = null;
			ApplicationContext context = this.contextResolver.getApplicationContext();
			if (context.getBeanNamesForType(EndpointHandlerMapping.class).length > 0) {
				endpointHandlerMapping = context.getBean(EndpointHandlerMapping.class);
			}
			if (endpointHandlerMapping == null) {
				// Maybe there are actually no endpoints (e.g. management.port=-1)
				endpointHandlerMapping = new EndpointHandlerMapping(
						Collections.<MvcEndpoint>emptySet());
			}
			return endpointHandlerMapping;
		}

	}

}
