Java tutorial
/* * Copyright 2013-2015 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.endpoint; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.BeanWrapperImpl; import org.springframework.beans.BeansException; import org.springframework.boot.context.properties.ConfigurationBeanFactoryMetaData; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.context.ApplicationContext; import org.springframework.context.ApplicationContextAware; import org.springframework.core.io.Resource; import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.util.ClassUtils; import org.springframework.util.StringUtils; import com.fasterxml.jackson.core.JsonParseException; import com.fasterxml.jackson.databind.BeanDescription; import com.fasterxml.jackson.databind.JsonMappingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationConfig; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.introspect.Annotated; import com.fasterxml.jackson.databind.introspect.AnnotatedMethod; import com.fasterxml.jackson.databind.introspect.JacksonAnnotationIntrospector; import com.fasterxml.jackson.databind.ser.BeanPropertyWriter; import com.fasterxml.jackson.databind.ser.BeanSerializerFactory; import com.fasterxml.jackson.databind.ser.BeanSerializerModifier; import com.fasterxml.jackson.databind.ser.PropertyWriter; import com.fasterxml.jackson.databind.ser.SerializerFactory; import com.fasterxml.jackson.databind.ser.impl.SimpleBeanPropertyFilter; import com.fasterxml.jackson.databind.ser.impl.SimpleFilterProvider; /** * {@link Endpoint} to expose application properties from {@link ConfigurationProperties} * annotated beans. * * <p> * To protect sensitive information from being exposed, certain property values are masked * if their names end with a set of configurable values (default "password" and "secret"). * Configure property names by using {@code endpoints.configprops.keys_to_sanitize} in * your Spring Boot application configuration. * * @author Christian Dupuis * @author Dave Syer */ @ConfigurationProperties(prefix = "endpoints.configprops", ignoreUnknownFields = false) public class ConfigurationPropertiesReportEndpoint extends AbstractEndpoint<Map<String, Object>> implements ApplicationContextAware { private static final String CGLIB_FILTER_ID = "cglibFilter"; private static final Log logger = LogFactory.getLog(ConfigurationPropertiesReportEndpoint.class); private final Sanitizer sanitizer = new Sanitizer(); private ApplicationContext context; private ConfigurationPropertiesMetaData metadata; private String metadataLocations = "classpath:*/META-INF/*spring-configuration-metadata.json"; public ConfigurationPropertiesReportEndpoint() { super("configprops"); } @Override public void setApplicationContext(ApplicationContext context) throws BeansException { this.context = context; } public void setKeysToSanitize(String... keysToSanitize) { this.sanitizer.setKeysToSanitize(keysToSanitize); } /** * Location path for JSON metadata about config properties. * @param metadataLocations the metadataLocations to set */ public void setMetadataLocations(String metadataLocations) { this.metadataLocations = metadataLocations; } @Override public Map<String, Object> invoke() { return extract(this.context); } /** * Extract beans annotated {@link ConfigurationProperties} and serialize into * {@link Map}. * @param context the application context * @return the beans */ protected Map<String, Object> extract(ApplicationContext context) { // Serialize beans into map structure and sanitize values ObjectMapper mapper = new ObjectMapper(); configureObjectMapper(mapper); return extract(context, mapper); } private Map<String, Object> extract(ApplicationContext context, ObjectMapper mapper) { Map<String, Object> result = new HashMap<String, Object>(); ConfigurationBeanFactoryMetaData beanFactoryMetaData = getBeanFactoryMetaData(context); Map<String, Object> beans = getConfigurationPropertiesBeans(context, beanFactoryMetaData); for (Map.Entry<String, Object> entry : beans.entrySet()) { String beanName = entry.getKey(); Object bean = entry.getValue(); Map<String, Object> root = new HashMap<String, Object>(); String prefix = extractPrefix(context, beanFactoryMetaData, beanName, bean); root.put("prefix", prefix); root.put("properties", sanitize(safeSerialize(mapper, bean, prefix))); result.put(beanName, root); } if (context.getParent() != null) { result.put("parent", extract(context.getParent(), mapper)); } return result; } private ConfigurationBeanFactoryMetaData getBeanFactoryMetaData(ApplicationContext context) { Map<String, ConfigurationBeanFactoryMetaData> beans = context .getBeansOfType(ConfigurationBeanFactoryMetaData.class); if (beans.size() == 1) { return beans.values().iterator().next(); } return null; } private Map<String, Object> getConfigurationPropertiesBeans(ApplicationContext context, ConfigurationBeanFactoryMetaData beanFactoryMetaData) { Map<String, Object> beans = new HashMap<String, Object>(); beans.putAll(context.getBeansWithAnnotation(ConfigurationProperties.class)); if (beanFactoryMetaData != null) { beans.putAll(beanFactoryMetaData.getBeansWithFactoryAnnotation(ConfigurationProperties.class)); } return beans; } /** * Cautiously serialize the bean to a map (returning a map with an error message * instead of throwing an exception if there is a problem). */ private Map<String, Object> safeSerialize(ObjectMapper mapper, Object bean, String prefix) { if (this.metadata == null) { this.metadata = new ConfigurationPropertiesMetaData(this.metadataLocations); } try { @SuppressWarnings("unchecked") Map<String, Object> result = new HashMap<String, Object>( mapper.convertValue(this.metadata.extractMap(bean, prefix), Map.class)); return result; } catch (Exception ex) { return new HashMap<String, Object>( Collections.<String, Object>singletonMap("error", "Cannot serialize '" + prefix + "'")); } } /** * Configure Jackson's {@link ObjectMapper} to be used to serialize the * {@link ConfigurationProperties} objects into a {@link Map} structure. * @param mapper the object mapper */ protected void configureObjectMapper(ObjectMapper mapper) { mapper.configure(SerializationFeature.FAIL_ON_EMPTY_BEANS, false); mapper.configure(SerializationFeature.WRITE_NULL_MAP_VALUES, false); applyCglibFilters(mapper); applySerializationModifier(mapper); } /** * Ensure only bindable and non-cyclic bean properties are reported. */ private void applySerializationModifier(ObjectMapper mapper) { SerializerFactory factory = BeanSerializerFactory.instance .withSerializerModifier(new GenericSerializerModifier()); mapper.setSerializerFactory(factory); } /** * Configure PropertyFilter to make sure Jackson doesn't process CGLIB generated bean * properties. */ private void applyCglibFilters(ObjectMapper mapper) { mapper.setAnnotationIntrospector(new CglibAnnotationIntrospector()); mapper.setFilters(new SimpleFilterProvider().addFilter(CGLIB_FILTER_ID, new CglibBeanPropertyFilter())); } /** * Extract configuration prefix from {@link ConfigurationProperties} annotation. * @param beanFactoryMetaData */ private String extractPrefix(ApplicationContext context, ConfigurationBeanFactoryMetaData beanFactoryMetaData, String beanName, Object bean) { ConfigurationProperties annotation = context.findAnnotationOnBean(beanName, ConfigurationProperties.class); if (beanFactoryMetaData != null) { ConfigurationProperties override = beanFactoryMetaData.findFactoryAnnotation(beanName, ConfigurationProperties.class); if (override != null) { // The @Bean-level @ConfigurationProperties overrides the one at type // level when binding. Arguably we should render them both, but this one // might be the most relevant for a starting point. annotation = override; } } return (StringUtils.hasLength(annotation.value()) ? annotation.value() : annotation.prefix()); } /** * Sanitize all unwanted configuration properties to avoid leaking of sensitive * information. */ @SuppressWarnings("unchecked") private Map<String, Object> sanitize(Map<String, Object> map) { for (Map.Entry<String, Object> entry : map.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); if (value instanceof Map) { map.put(key, sanitize((Map<String, Object>) value)); } else { map.put(key, this.sanitizer.sanitize(key, value)); } } return map; } /** * Extension to {@link JacksonAnnotationIntrospector} to suppress CGLIB generated bean * properties. */ @SuppressWarnings("serial") private static class CglibAnnotationIntrospector extends JacksonAnnotationIntrospector { @Override public Object findFilterId(Annotated a) { Object id = super.findFilterId(a); if (id == null) { id = CGLIB_FILTER_ID; } return id; } } /** * {@link SimpleBeanPropertyFilter} to filter out all bean properties whose names * start with '$$'. */ private static class CglibBeanPropertyFilter extends SimpleBeanPropertyFilter { @Override protected boolean include(BeanPropertyWriter writer) { return include(writer.getFullName().getSimpleName()); } @Override protected boolean include(PropertyWriter writer) { return include(writer.getFullName().getSimpleName()); } private boolean include(String name) { return !name.startsWith("$$"); } } /** * {@link BeanSerializerModifier} to return only relevant configuration properties. */ protected static class GenericSerializerModifier extends BeanSerializerModifier { @Override public List<BeanPropertyWriter> changeProperties(SerializationConfig config, BeanDescription beanDesc, List<BeanPropertyWriter> beanProperties) { List<BeanPropertyWriter> result = new ArrayList<BeanPropertyWriter>(); for (BeanPropertyWriter writer : beanProperties) { boolean readable = isReadable(beanDesc, writer); if (readable) { result.add(writer); } } return result; } private boolean isReadable(BeanDescription beanDesc, BeanPropertyWriter writer) { String parentType = beanDesc.getType().getRawClass().getName(); String type = writer.getPropertyType().getName(); AnnotatedMethod setter = findSetter(beanDesc, writer); // If there's a setter, we assume it's OK to report on the value, // similarly, if there's no setter but the package names match, we assume // that its a nested class used solely for binding to config props, so it // should be kosher. This filter is not used if there is JSON metadata for // the property, so it's mainly for user-defined beans. return (setter != null) || ClassUtils.getPackageName(parentType).equals(ClassUtils.getPackageName(type)); } private AnnotatedMethod findSetter(BeanDescription beanDesc, BeanPropertyWriter writer) { String name = "set" + StringUtils.capitalize(writer.getName()); Class<?> type = writer.getPropertyType(); AnnotatedMethod setter = beanDesc.findMethod(name, new Class<?>[] { type }); // The enabled property of endpoints returns a boolean primitive but is set // using a Boolean class if (setter == null && type.equals(Boolean.TYPE)) { setter = beanDesc.findMethod(name, new Class<?>[] { Boolean.class }); } return setter; } } /** * Convenience class for grabbing and caching valid property names from * /META-INF/spring-configuration-metadata.json so that metadata that is known to be * valid can be used to pull the correct nested properties out of beans that might * otherwise be tricky (contain cycles or other unserializable properties). */ protected static class ConfigurationPropertiesMetaData { private final String metadataLocations; private final Map<String, Set<String>> matched = new HashMap<String, Set<String>>(); private Set<String> keys = null; public ConfigurationPropertiesMetaData(String metadataLocations) { this.metadataLocations = metadataLocations; } public boolean matches(String prefix) { if (this.matched.containsKey(prefix)) { return matchesInternal(prefix); } synchronized (this.matched) { if (this.matched.containsKey(prefix)) { return matchesInternal(prefix); } this.matched.put(prefix, findKeys(prefix)); } return matchesInternal(prefix); } private boolean matchesInternal(String prefix) { return this.matched.get(prefix) != null; } private Set<String> findKeys(String prefix) { HashSet<String> keys = new HashSet<String>(); for (String key : getKeys()) { if (key.length() > prefix.length() && key.startsWith(prefix) && ".".equals(key.substring(prefix.length(), prefix.length() + 1))) { keys.add(key.substring(prefix.length() + 1)); } } return (keys.isEmpty() ? null : keys); } private Set<String> getKeys() { if (this.keys != null) { return this.keys; } this.keys = new HashSet<String>(); try { ObjectMapper mapper = new ObjectMapper(); Resource[] resources = new PathMatchingResourcePatternResolver() .getResources(this.metadataLocations); for (Resource resource : resources) { addKeys(mapper, resource); } } catch (IOException ex) { logger.warn("Could not deserialize config properties metadata", ex); } return this.keys; } @SuppressWarnings("unchecked") private void addKeys(ObjectMapper mapper, Resource resource) throws IOException, JsonParseException, JsonMappingException { InputStream inputStream = resource.getInputStream(); Map<String, Object> map = mapper.readValue(inputStream, Map.class); Collection<Map<String, Object>> metadata = (Collection<Map<String, Object>>) map.get("properties"); for (Map<String, Object> value : metadata) { try { if (value.containsKey("type")) { this.keys.add((String) value.get("name")); } } catch (Exception ex) { logger.warn("Could not parse config properties metadata", ex); } } } public Object extractMap(Object bean, String prefix) { if (!matches(prefix)) { return bean; } Map<String, Object> map = new HashMap<String, Object>(); for (String key : this.matched.get(prefix)) { addProperty(bean, key, map); } return map; } @SuppressWarnings("unchecked") private void addProperty(Object bean, String key, Map<String, Object> map) { String prefix = (key.contains(".") ? StringUtils.split(key, ".")[0] : key); String suffix = (key.length() > prefix.length() ? key.substring(prefix.length() + 1) : null); String property = prefix; if (bean instanceof Map) { Map<String, Object> value = (Map<String, Object>) bean; bean = new MapHolder(value); property = "map[" + property + "]"; } BeanWrapperImpl wrapper = new BeanWrapperImpl(bean); try { Object value = wrapper.getPropertyValue(property); if (value instanceof Map) { Map<String, Object> nested = new HashMap<String, Object>(); map.put(prefix, nested); if (suffix != null) { addProperty(value, suffix, nested); } } else { map.put(prefix, value); } } catch (Exception ex) { // Probably just lives on a different bean (it happens) logger.debug("Could not parse config properties metadata '" + key + "': " + ex.getMessage()); } } protected static class MapHolder { Map<String, Object> map = new HashMap<String, Object>(); public MapHolder(Map<String, Object> bean) { this.map.putAll(bean); } public Map<String, Object> getMap() { return this.map; } public void setMap(Map<String, Object> map) { this.map = map; } } } }