diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml b/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml index 4a977c5d43..ab8b9bb38f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/pom.xml @@ -348,6 +348,16 @@ logback-classic test + + io.projectreactor + reactor-test + test + + + com.squareup.okhttp3 + mockwebserver + test + com.jayway.jsonpath json-path diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java index 3f0d16a336..a7e5efbb4f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AccessLevel.java @@ -19,15 +19,13 @@ package org.springframework.boot.actuate.autoconfigure.cloudfoundry; import java.util.Arrays; import java.util.List; -import javax.servlet.http.HttpServletRequest; - /** * The specific access level granted to the cloud foundry user that's calling the * endpoints. * * @author Madhura Bhave */ -enum AccessLevel { +public enum AccessLevel { /** * Restricted access to a limited set of endpoints. @@ -39,7 +37,7 @@ enum AccessLevel { */ FULL; - private static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel"; + public static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel"; private final List endpointPaths; @@ -56,12 +54,4 @@ enum AccessLevel { return this.endpointPaths.isEmpty() || this.endpointPaths.contains(endpointPath); } - public void put(HttpServletRequest request) { - request.setAttribute(REQUEST_ATTRIBUTE, this); - } - - public static AccessLevel get(HttpServletRequest request) { - return (AccessLevel) request.getAttribute(REQUEST_ATTRIBUTE); - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java index c2ee514a69..5e419ee38f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryAuthorizationException.java @@ -23,15 +23,15 @@ import org.springframework.http.HttpStatus; * * @author Madhura Bhave */ -class CloudFoundryAuthorizationException extends RuntimeException { +public class CloudFoundryAuthorizationException extends RuntimeException { private final Reason reason; - CloudFoundryAuthorizationException(Reason reason, String message) { + public CloudFoundryAuthorizationException(Reason reason, String message) { this(reason, message, null); } - CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) { + public CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) { super(message); this.reason = reason; } @@ -55,7 +55,7 @@ class CloudFoundryAuthorizationException extends RuntimeException { /** * Reasons why the exception can be thrown. */ - enum Reason { + public enum Reason { ACCESS_DENIED(HttpStatus.FORBIDDEN), diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SecurityResponse.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SecurityResponse.java new file mode 100644 index 0000000000..d484be2d62 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SecurityResponse.java @@ -0,0 +1,53 @@ +/* + * 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.cloudfoundry; + +import org.springframework.http.HttpStatus; + +/** + * Response from the Cloud Foundry security interceptors. + * + * @author Madhura Bhave + */ +public class SecurityResponse { + + private final HttpStatus status; + + private final String message; + + public SecurityResponse(HttpStatus status) { + this(status, null); + } + + public SecurityResponse(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public HttpStatus getStatus() { + return this.status; + } + + public String getMessage() { + return this.message; + } + + public static SecurityResponse success() { + return new SecurityResponse(HttpStatus.OK); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java index cc6b52961c..11d21b1ab2 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/Token.java @@ -20,6 +20,7 @@ import java.nio.charset.Charset; import java.util.List; import java.util.Map; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; import org.springframework.boot.json.JsonParserFactory; import org.springframework.util.Base64Utils; import org.springframework.util.StringUtils; @@ -29,7 +30,7 @@ import org.springframework.util.StringUtils; * * @author Madhura Bhave */ -class Token { +public class Token { private static final Charset UTF_8 = Charset.forName("UTF-8"); @@ -41,13 +42,13 @@ class Token { private final Map claims; - Token(String encoded) { + public Token(String encoded) { this.encoded = encoded; int firstPeriod = encoded.indexOf('.'); int lastPeriod = encoded.lastIndexOf('.'); if (firstPeriod <= 0 || lastPeriod <= firstPeriod) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, + Reason.INVALID_TOKEN, "JWT must have header, body and signature"); } this.header = parseJson(encoded.substring(0, firstPeriod)); @@ -55,7 +56,7 @@ class Token { this.signature = encoded.substring(lastPeriod + 1); if (!StringUtils.hasLength(this.signature)) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, + Reason.INVALID_TOKEN, "Token must have non-empty crypto segment"); } } @@ -67,7 +68,7 @@ class Token { } catch (RuntimeException ex) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, + Reason.INVALID_TOKEN, "Token could not be parsed", ex); } } @@ -106,12 +107,12 @@ class Token { Object value = map.get(key); if (value == null) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, + Reason.INVALID_TOKEN, "Unable to get value from key " + key); } if (!type.isInstance(value)) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, + Reason.INVALID_TOKEN, "Unexpected value type from key " + key + " value " + value); } return (T) value; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java new file mode 100644 index 0000000000..17189f8f39 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointHandlerMapping.java @@ -0,0 +1,233 @@ +/* + * 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.cloudfoundry.reactive; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.endpoint.EndpointInfo; +import org.springframework.boot.actuate.endpoint.OperationInvoker; +import org.springframework.boot.actuate.endpoint.OperationType; +import org.springframework.boot.actuate.endpoint.ParameterMappingException; +import org.springframework.boot.actuate.endpoint.ParametersMissingException; +import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; +import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; +import org.springframework.boot.actuate.endpoint.web.Link; +import org.springframework.boot.actuate.endpoint.web.WebEndpointOperation; +import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; +import org.springframework.boot.actuate.endpoint.web.reactive.AbstractWebFluxEndpointHandlerMapping; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.ReflectionUtils; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping; + +/** + * A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on + * Cloud Foundry specific URLs over HTTP using Spring WebFlux. + * + * @author Madhura Bhave + */ +public class CloudFoundryWebFluxEndpointHandlerMapping extends AbstractWebFluxEndpointHandlerMapping { + + private final Method handleRead = ReflectionUtils + .findMethod(ReadOperationHandler.class, "handle", ServerWebExchange.class); + + private final Method handleWrite = ReflectionUtils.findMethod( + WriteOperationHandler.class, "handle", ServerWebExchange.class, Map.class); + + private final Method links = ReflectionUtils.findMethod(getClass(), "links", + ServerWebExchange.class); + + private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); + + private final ReactiveCloudFoundrySecurityInterceptor securityInterceptor; + + @Override + protected Method getLinks() { + return this.links; + } + + @Override + protected void registerMappingForOperation(WebEndpointOperation operation) { + OperationType operationType = operation.getType(); + OperationInvoker operationInvoker = operation.getInvoker(); + if (operation.isBlocking()) { + operationInvoker = new ElasticSchedulerOperationInvoker(operationInvoker); + } + registerMapping(createRequestMappingInfo(operation), + operationType == OperationType.WRITE + ? new WriteOperationHandler(operationInvoker, operation.getId()) + : new ReadOperationHandler(operationInvoker, operation.getId()), + operationType == OperationType.WRITE ? this.handleWrite + : this.handleRead); + } + + @ResponseBody + private Publisher> links(ServerWebExchange exchange) { + ServerHttpRequest request = exchange.getRequest(); + return this.securityInterceptor + .preHandle(exchange, "") + .map(securityResponse -> { + if (!securityResponse.getStatus().equals(HttpStatus.OK)) { + return new ResponseEntity<>(securityResponse.getStatus()); + } + AccessLevel accessLevel = exchange.getAttribute(AccessLevel.REQUEST_ATTRIBUTE); + Map links = this.endpointLinksResolver.resolveLinks(getEndpoints(), + request.getURI().toString()); + return new ResponseEntity<>(Collections.singletonMap("_links", + getAccessibleLinks(accessLevel, links)), HttpStatus.OK); + }); + } + + private Map getAccessibleLinks(AccessLevel accessLevel, Map links) { + if (accessLevel == null) { + return new LinkedHashMap<>(); + } + return links.entrySet().stream() + .filter((e) -> e.getKey().equals("self") + || accessLevel.isAccessAllowed(e.getKey())) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); + } + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointMapping the base mapping for all endpoints + * @param webEndpoints the web endpoints + * @param endpointMediaTypes media types consumed and produced by the endpoints + * @param corsConfiguration the CORS configuration for the endpoints + * @param securityInterceptor the Security Interceptor + */ + public CloudFoundryWebFluxEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection> webEndpoints, + EndpointMediaTypes endpointMediaTypes, CorsConfiguration corsConfiguration, + ReactiveCloudFoundrySecurityInterceptor securityInterceptor) { + super(endpointMapping, webEndpoints, endpointMediaTypes, corsConfiguration); + this.securityInterceptor = securityInterceptor; + } + + /** + * Base class for handlers for endpoint operations. + */ + abstract class AbstractOperationHandler { + + private final OperationInvoker operationInvoker; + + private final String endpointId; + + private final ReactiveCloudFoundrySecurityInterceptor securityInterceptor; + + AbstractOperationHandler(OperationInvoker operationInvoker, String endpointId, ReactiveCloudFoundrySecurityInterceptor securityInterceptor) { + this.operationInvoker = operationInvoker; + this.endpointId = endpointId; + this.securityInterceptor = securityInterceptor; + } + + @SuppressWarnings({ "unchecked" }) + Publisher> doHandle(ServerWebExchange exchange, + Map body) { + return this.securityInterceptor + .preHandle(exchange, this.endpointId) + .flatMap(securityResponse -> { + if (!securityResponse.getStatus().equals(HttpStatus.OK)) { + return Mono.just(new ResponseEntity<>(securityResponse.getStatus())); + } + Map arguments = new HashMap<>(exchange + .getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE)); + if (body != null) { + arguments.putAll(body); + } + exchange.getRequest().getQueryParams().forEach((name, values) -> arguments + .put(name, values.size() == 1 ? values.get(0) : values)); + return handleResult((Publisher) this.operationInvoker.invoke(arguments), + exchange.getRequest().getMethod()); + }); + } + + private Mono> handleResult(Publisher result, + HttpMethod httpMethod) { + return Mono.from(result).map(this::toResponseEntity) + .onErrorReturn(ParametersMissingException.class, + new ResponseEntity<>(HttpStatus.BAD_REQUEST)) + .onErrorReturn(ParameterMappingException.class, + new ResponseEntity<>(HttpStatus.BAD_REQUEST)) + .defaultIfEmpty(new ResponseEntity<>(httpMethod == HttpMethod.GET + ? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT)); + } + + private ResponseEntity toResponseEntity(Object response) { + if (!(response instanceof WebEndpointResponse)) { + return new ResponseEntity<>(response, HttpStatus.OK); + } + WebEndpointResponse webEndpointResponse = (WebEndpointResponse) response; + return new ResponseEntity<>(webEndpointResponse.getBody(), + HttpStatus.valueOf(webEndpointResponse.getStatus())); + } + + } + + /** + * A handler for an endpoint write operation. + */ + final class WriteOperationHandler extends AbstractOperationHandler { + + WriteOperationHandler(OperationInvoker operationInvoker, String endpointId) { + super(operationInvoker, endpointId, CloudFoundryWebFluxEndpointHandlerMapping.this.securityInterceptor); + } + + @ResponseBody + public Publisher> handle(ServerWebExchange exchange, + @RequestBody(required = false) Map body) { + return doHandle(exchange, body); + } + + } + + /** + * A handler for an endpoint write operation. + */ + final class ReadOperationHandler extends AbstractOperationHandler { + + ReadOperationHandler(OperationInvoker operationInvoker, String endpointId) { + super(operationInvoker, endpointId, CloudFoundryWebFluxEndpointHandlerMapping.this.securityInterceptor); + } + + @ResponseBody + public Publisher> handle(ServerWebExchange exchange) { + return doHandle(exchange, null); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java new file mode 100644 index 0000000000..dce41e93f3 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfiguration.java @@ -0,0 +1,140 @@ +/* + * 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.cloudfoundry.reactive; + +import java.util.Arrays; +import java.util.Collections; + +import org.springframework.beans.BeansException; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.boot.actuate.autoconfigure.endpoint.DefaultCachingConfigurationFactory; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.endpoint.ParameterMapper; +import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; +import org.springframework.boot.actuate.endpoint.web.annotation.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.cloud.CloudPlatform; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; +import org.springframework.http.HttpMethod; +import org.springframework.security.web.server.MatcherSecurityWebFilterChain; +import org.springframework.security.web.server.WebFilterChainProxy; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.server.WebFilter; + +/** + * {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for + * cloud foundry to use in a reactive environment. + * + * @author Madhura Bhave + * @since 2.0.0 + */ +@Configuration +@ConditionalOnProperty(prefix = "management.cloudfoundry", name = "enabled", matchIfMissing = true) +@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) +@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY) +public class ReactiveCloudFoundryActuatorAutoConfiguration { + + private final ApplicationContext applicationContext; + + ReactiveCloudFoundryActuatorAutoConfiguration(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } + + @Bean + public CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebFluxEndpointHandlerMapping( + ParameterMapper parameterMapper, EndpointMediaTypes endpointMediaTypes, + WebClient.Builder webClientBuilder, Environment environment, + DefaultCachingConfigurationFactory cachingConfigurationFactory, WebEndpointProperties webEndpointProperties) { + WebAnnotationEndpointDiscoverer endpointDiscoverer = new WebAnnotationEndpointDiscoverer( + this.applicationContext, parameterMapper, cachingConfigurationFactory, + endpointMediaTypes, (id) -> id); + return new CloudFoundryWebFluxEndpointHandlerMapping( + new EndpointMapping("/cloudfoundryapplication"), + endpointDiscoverer.discoverEndpoints(), endpointMediaTypes, getCorsConfiguration(), getSecurityInterceptor(webClientBuilder, environment)); + } + + private ReactiveCloudFoundrySecurityInterceptor getSecurityInterceptor( + WebClient.Builder restTemplateBuilder, Environment environment) { + ReactiveCloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService( + restTemplateBuilder, environment); + ReactiveTokenValidator tokenValidator = new ReactiveTokenValidator( + cloudfoundrySecurityService); + return new ReactiveCloudFoundrySecurityInterceptor(tokenValidator, + cloudfoundrySecurityService, + environment.getProperty("vcap.application.application_id")); + } + + private ReactiveCloudFoundrySecurityService getCloudFoundrySecurityService( + WebClient.Builder webClientBuilder, Environment environment) { + String cloudControllerUrl = environment + .getProperty("vcap.application.cf_api"); + return (cloudControllerUrl == null ? null + : new ReactiveCloudFoundrySecurityService(webClientBuilder, + cloudControllerUrl)); + } + + private CorsConfiguration getCorsConfiguration() { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL); + corsConfiguration.setAllowedMethods( + Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name())); + corsConfiguration.setAllowedHeaders( + Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type")); + return corsConfiguration; + } + + @Configuration + @ConditionalOnClass(MatcherSecurityWebFilterChain.class) + static class IgnoredPathsSecurityConfiguration { + @Bean + public BeanPostProcessor webFilterChainPostProcessor() { + return new BeanPostProcessor() { + @Override + public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException { + if (bean instanceof WebFilterChainProxy) { + return postProcess((WebFilterChainProxy) bean); + } + return bean; + } + }; + } + + WebFilterChainProxy postProcess(WebFilterChainProxy existing) { + ServerWebExchangeMatcher cloudFoundryRequestMatcher = ServerWebExchangeMatchers.pathMatchers( + "/cloudfoundryapplication/**"); + WebFilter noOpFilter = (exchange, chain) -> chain.filter(exchange); + MatcherSecurityWebFilterChain ignoredRequestFilterChain = new MatcherSecurityWebFilterChain( + cloudFoundryRequestMatcher, Collections.singletonList(noOpFilter)); + MatcherSecurityWebFilterChain allRequestsFilterChain = new MatcherSecurityWebFilterChain( + ServerWebExchangeMatchers.anyExchange(), Collections.singletonList(existing)); + return new WebFilterChainProxy(ignoredRequestFilterChain, allRequestsFilterChain); + } + } + +} + diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityInterceptor.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityInterceptor.java new file mode 100644 index 0000000000..755423ffc6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityInterceptor.java @@ -0,0 +1,117 @@ +/* + * 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.cloudfoundry.reactive; + +import org.apache.commons.logging.Log; +import org.apache.commons.logging.LogFactory; +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.SecurityResponse; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; +import org.springframework.http.HttpStatus; +import org.springframework.http.server.reactive.ServerHttpRequest; +import org.springframework.util.StringUtils; +import org.springframework.web.cors.reactive.CorsUtils; +import org.springframework.web.server.ServerWebExchange; + +/** + * Security interceptor to validate the cloud foundry token. + * + * @author Madhura Bhave + */ +class ReactiveCloudFoundrySecurityInterceptor { + + private static final Log logger = LogFactory + .getLog(ReactiveCloudFoundrySecurityInterceptor.class); + + private final ReactiveTokenValidator tokenValidator; + + private final ReactiveCloudFoundrySecurityService cloudFoundrySecurityService; + + private final String applicationId; + + private static Mono SUCCESS = Mono.just(SecurityResponse.success()); + + ReactiveCloudFoundrySecurityInterceptor(ReactiveTokenValidator tokenValidator, + ReactiveCloudFoundrySecurityService cloudFoundrySecurityService, + String applicationId) { + this.tokenValidator = tokenValidator; + this.cloudFoundrySecurityService = cloudFoundrySecurityService; + this.applicationId = applicationId; + } + + Mono preHandle(ServerWebExchange exchange, String endpointId) { + ServerHttpRequest request = exchange.getRequest(); + if (CorsUtils.isPreFlightRequest(request)) { + return SUCCESS; + } + if (!StringUtils.hasText(this.applicationId)) { + return Mono.error(new CloudFoundryAuthorizationException( + Reason.SERVICE_UNAVAILABLE, + "Application id is not available")); + } + if (this.cloudFoundrySecurityService == null) { + return Mono.error(new CloudFoundryAuthorizationException( + Reason.SERVICE_UNAVAILABLE, + "Cloud controller URL is not available")); + } + return check(exchange, endpointId) + .then(SUCCESS) + .doOnError(throwable -> logger.error(throwable.getMessage(), throwable)) + .onErrorResume(this::getErrorResponse); + } + + private Mono check(ServerWebExchange exchange, String path) { + try { + Token token = getToken(exchange.getRequest()); + return this.tokenValidator.validate(token).then(this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId)) + .filter(accessLevel -> accessLevel.isAccessAllowed(path)) + .switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, + "Access denied"))) + .doOnSuccess(accessLevel -> exchange.getAttributes().put("cloudFoundryAccessLevel", accessLevel)) + .then(); + } + catch (CloudFoundryAuthorizationException ex) { + return Mono.error(ex); + } + } + + private Mono getErrorResponse(Throwable throwable) { + if (throwable instanceof CloudFoundryAuthorizationException) { + CloudFoundryAuthorizationException cfException = (CloudFoundryAuthorizationException) throwable; + return Mono.just(new SecurityResponse(cfException.getStatusCode(), + "{\"security_error\":\"" + cfException.getMessage() + "\"}")); + } + return Mono.just(new SecurityResponse(HttpStatus.INTERNAL_SERVER_ERROR, + throwable.getMessage())); + } + + private Token getToken(ServerHttpRequest request) { + String authorization = request.getHeaders().getFirst("Authorization"); + String bearerPrefix = "bearer "; + if (authorization == null + || !authorization.toLowerCase().startsWith(bearerPrefix)) { + throw new CloudFoundryAuthorizationException( + Reason.MISSING_AUTHORIZATION, + "Authorization header is missing or invalid"); + } + return new Token(authorization.substring(bearerPrefix.length())); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java new file mode 100644 index 0000000000..623904a247 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityService.java @@ -0,0 +1,136 @@ +/* + * 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.cloudfoundry.reactive; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientResponseException; + +/** + * Reactive Cloud Foundry security service to handle REST calls to the cloud controller and UAA. + * + * @author Madhura Bhave + */ +public class ReactiveCloudFoundrySecurityService { + + private final WebClient webClient; + + private final String cloudControllerUrl; + + private Mono uaaUrl; + + ReactiveCloudFoundrySecurityService(WebClient.Builder webClientBuilder, + String cloudControllerUrl) { + Assert.notNull(webClientBuilder, "Webclient must not be null"); + Assert.notNull(cloudControllerUrl, "CloudControllerUrl must not be null"); + this.webClient = webClientBuilder.build(); + this.cloudControllerUrl = cloudControllerUrl; + } + + /** + * Return a Mono of the access level that should be granted to the given token. + * @param token the token + * @param applicationId the cloud foundry application ID + * @return a Mono of the access level that should be granted + * @throws CloudFoundryAuthorizationException if the token is not authorized + */ + public Mono getAccessLevel(String token, String applicationId) + throws CloudFoundryAuthorizationException { + String uri = getPermissionsUri(applicationId); + return this.webClient.get().uri(uri) + .header("Authorization", "bearer " + token) + .retrieve().bodyToMono(Map.class) + .map(this::getAccessLevel) + .onErrorMap(throwable -> { + if (throwable instanceof WebClientResponseException) { + HttpStatus statusCode = ((WebClientResponseException) throwable).getStatusCode(); + if (statusCode.equals(HttpStatus.FORBIDDEN)) { + return new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, + "Access denied"); + } + if (statusCode.is4xxClientError()) { + return new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, + "Invalid token", throwable); + } + } + return new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, + "Cloud controller not reachable"); + }); + } + + private AccessLevel getAccessLevel(Map body) { + if (Boolean.TRUE.equals(body.get("read_sensitive_data"))) { + return AccessLevel.FULL; + } + return AccessLevel.RESTRICTED; + } + + private String getPermissionsUri(String applicationId) { + return this.cloudControllerUrl + "/v2/apps/" + applicationId + + "/permissions"; + } + + /** + * Return a Mono of all token keys known by the UAA. + * @return a Mono of token keys + */ + public Mono> fetchTokenKeys() { + return getUaaUrl() + .flatMap(url -> this.webClient.get() + .uri(url + "/token_keys") + .retrieve().bodyToMono(new ParameterizedTypeReference>() { }) + .map(this::extractTokenKeys) + .onErrorMap((throwable -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, + throwable.getMessage())))); + + } + + private Map extractTokenKeys(Map response) { + Map tokenKeys = new HashMap<>(); + for (Object key : (List) response.get("keys")) { + Map tokenKey = (Map) key; + tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value")); + } + return tokenKeys; + } + + /** + * Return a Mono of URL of the UAA. + * @return the UAA url Mono + */ + public Mono getUaaUrl() { + this.uaaUrl = this.webClient + .get().uri(this.cloudControllerUrl + "/info") + .retrieve().bodyToMono(Map.class) + .map(response -> (String) response.get("token_endpoint")).cache() + .onErrorMap(throwable -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, + "Unable to fetch token keys from UAA.")); + return this.uaaUrl; + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java new file mode 100644 index 0000000000..16cbe632b9 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidator.java @@ -0,0 +1,141 @@ +/* + * 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.cloudfoundry.reactive; + +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.Signature; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; +import org.springframework.util.Base64Utils; + +/** + * Validator used to ensure that a signed {@link Token} has not been tampered with. + * + * @author Madhura Bhave + */ +public class ReactiveTokenValidator { + + private final ReactiveCloudFoundrySecurityService securityService; + + public ReactiveTokenValidator(ReactiveCloudFoundrySecurityService securityService) { + this.securityService = securityService; + } + + public Mono validate(Token token) { + return validateAlgorithm(token) + .then(validateKeyIdAndSignature(token)) + .then(validateExpiry(token)) + .then(validateIssuer(token)) + .then(validateAudience(token)); + } + + private Mono validateAlgorithm(Token token) { + String algorithm = token.getSignatureAlgorithm(); + if (algorithm == null) { + return Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE, + "Signing algorithm cannot be null")); + } + if (!algorithm.equals("RS256")) { + return Mono.error(new CloudFoundryAuthorizationException( + Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM, + "Signing algorithm " + algorithm + " not supported")); + } + return Mono.empty(); + } + + private Mono validateKeyIdAndSignature(Token token) { + String keyId = token.getKeyId(); + return this.securityService.fetchTokenKeys() + .filter(tokenKeys -> hasValidKeyId(keyId, tokenKeys)) + .switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_KEY_ID, + "Key Id present in token header does not match"))) + .filter(tokenKeys -> hasValidSignature(token, tokenKeys.get(keyId))) + .switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE, + "RSA Signature did not match content"))) + .then(); + } + + private boolean hasValidKeyId(String keyId, Map tokenKeys) { + for (String candidate : tokenKeys.keySet()) { + if (keyId.equals(candidate)) { + return true; + } + } + return false; + } + + private boolean hasValidSignature(Token token, String key) { + try { + PublicKey publicKey = getPublicKey(key); + Signature signature = Signature.getInstance("SHA256withRSA"); + signature.initVerify(publicKey); + signature.update(token.getContent()); + return signature.verify(token.getSignature()); + } + catch (GeneralSecurityException ex) { + return false; + } + } + + private PublicKey getPublicKey(String key) + throws NoSuchAlgorithmException, InvalidKeySpecException { + key = key.replace("-----BEGIN PUBLIC KEY-----\n", ""); + key = key.replace("-----END PUBLIC KEY-----", ""); + key = key.trim().replace("\n", ""); + byte[] bytes = Base64Utils.decodeFromString(key); + X509EncodedKeySpec keySpec = new X509EncodedKeySpec(bytes); + return KeyFactory.getInstance("RSA").generatePublic(keySpec); + } + + private Mono validateExpiry(Token token) { + long currentTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis()); + if (currentTime > token.getExpiry()) { + return Mono.error(new CloudFoundryAuthorizationException(Reason.TOKEN_EXPIRED, + "Token expired")); + } + return Mono.empty(); + } + + private Mono validateIssuer(Token token) { + return this.securityService.getUaaUrl() + .map(uaaUrl -> String.format("%s/oauth/token", uaaUrl)) + .filter(issuerUri -> issuerUri.equals(token.getIssuer())) + .switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_ISSUER, + "Token issuer does not match"))) + .then(); + } + + private Mono validateAudience(Token token) { + if (!token.getScope().contains("actuator.read")) { + return Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_AUDIENCE, + "Token does not have audience actuator")); + } + return Mono.empty(); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java similarity index 54% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java index fefe9b801d..fed36e974f 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.util.Arrays; @@ -26,11 +26,9 @@ import org.springframework.boot.actuate.endpoint.web.EndpointPathResolver; import org.springframework.boot.actuate.endpoint.web.annotation.WebAnnotationEndpointDiscoverer; import org.springframework.boot.autoconfigure.AutoConfigureAfter; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.autoconfigure.security.SecurityProperties; import org.springframework.boot.cloud.CloudPlatform; import org.springframework.boot.endpoint.web.EndpointMapping; @@ -45,7 +43,6 @@ import org.springframework.security.config.annotation.web.WebSecurityConfigurer; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.servlet.DispatcherServlet; /** * {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for @@ -60,68 +57,57 @@ import org.springframework.web.servlet.DispatcherServlet; @ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY) public class CloudFoundryActuatorAutoConfiguration { - /** - * Configuration for MVC endpoints on Cloud Foundry. - */ - @Configuration - @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET) - @ConditionalOnClass(DispatcherServlet.class) - @ConditionalOnBean(DispatcherServlet.class) - static class MvcWebEndpointConfiguration { - - private final ApplicationContext applicationContext; + private final ApplicationContext applicationContext; - MvcWebEndpointConfiguration(ApplicationContext applicationContext) { - this.applicationContext = applicationContext; - } - - @Bean - public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping( - ParameterMapper parameterMapper, - DefaultCachingConfigurationFactory cachingConfigurationFactory, - EndpointMediaTypes endpointMediaTypes, Environment environment, - RestTemplateBuilder builder) { - WebAnnotationEndpointDiscoverer endpointDiscoverer = new WebAnnotationEndpointDiscoverer( - this.applicationContext, parameterMapper, cachingConfigurationFactory, - endpointMediaTypes, EndpointPathResolver.useEndpointId()); - return new CloudFoundryWebEndpointServletHandlerMapping( - new EndpointMapping("/cloudfoundryapplication"), - endpointDiscoverer.discoverEndpoints(), endpointMediaTypes, - getCorsConfiguration(), getSecurityInterceptor(builder, environment)); - } + CloudFoundryActuatorAutoConfiguration(ApplicationContext applicationContext) { + this.applicationContext = applicationContext; + } - private CloudFoundrySecurityInterceptor getSecurityInterceptor( - RestTemplateBuilder restTemplateBuilder, Environment environment) { - CloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService( - restTemplateBuilder, environment); - TokenValidator tokenValidator = new TokenValidator( - cloudfoundrySecurityService); - return new CloudFoundrySecurityInterceptor(tokenValidator, - cloudfoundrySecurityService, - environment.getProperty("vcap.application.application_id")); - } + @Bean + public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping( + ParameterMapper parameterMapper, + DefaultCachingConfigurationFactory cachingConfigurationFactory, + EndpointMediaTypes endpointMediaTypes, Environment environment, + RestTemplateBuilder builder) { + WebAnnotationEndpointDiscoverer endpointDiscoverer = new WebAnnotationEndpointDiscoverer( + this.applicationContext, parameterMapper, cachingConfigurationFactory, + endpointMediaTypes, EndpointPathResolver.useEndpointId()); + return new CloudFoundryWebEndpointServletHandlerMapping( + new EndpointMapping("/cloudfoundryapplication"), + endpointDiscoverer.discoverEndpoints(), endpointMediaTypes, + getCorsConfiguration(), getSecurityInterceptor(builder, environment)); + } - private CloudFoundrySecurityService getCloudFoundrySecurityService( - RestTemplateBuilder restTemplateBuilder, Environment environment) { - String cloudControllerUrl = environment - .getProperty("vcap.application.cf_api"); - boolean skipSslValidation = environment.getProperty( - "management.cloudfoundry.skip-ssl-validation", Boolean.class, false); - return (cloudControllerUrl == null ? null - : new CloudFoundrySecurityService(restTemplateBuilder, - cloudControllerUrl, skipSslValidation)); - } + private CloudFoundrySecurityInterceptor getSecurityInterceptor( + RestTemplateBuilder restTemplateBuilder, Environment environment) { + CloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService( + restTemplateBuilder, environment); + TokenValidator tokenValidator = new TokenValidator( + cloudfoundrySecurityService); + return new CloudFoundrySecurityInterceptor(tokenValidator, + cloudfoundrySecurityService, + environment.getProperty("vcap.application.application_id")); + } - private CorsConfiguration getCorsConfiguration() { - CorsConfiguration corsConfiguration = new CorsConfiguration(); - corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL); - corsConfiguration.setAllowedMethods( - Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name())); - corsConfiguration.setAllowedHeaders( - Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type")); - return corsConfiguration; - } + private CloudFoundrySecurityService getCloudFoundrySecurityService( + RestTemplateBuilder restTemplateBuilder, Environment environment) { + String cloudControllerUrl = environment + .getProperty("vcap.application.cf_api"); + boolean skipSslValidation = environment.getProperty( + "management.cloudfoundry.skip-ssl-validation", Boolean.class, false); + return (cloudControllerUrl == null ? null + : new CloudFoundrySecurityService(restTemplateBuilder, + cloudControllerUrl, skipSslValidation)); + } + private CorsConfiguration getCorsConfiguration() { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL); + corsConfiguration.setAllowedMethods( + Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name())); + corsConfiguration.setAllowedHeaders( + Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type")); + return corsConfiguration; } /** diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptor.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java similarity index 80% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptor.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java index 054af4fbe0..9b6bc51878 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptor.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java @@ -14,13 +14,18 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import javax.servlet.http.HttpServletRequest; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.SecurityResponse; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.util.StringUtils; @@ -59,12 +64,12 @@ class CloudFoundrySecurityInterceptor { try { if (!StringUtils.hasText(this.applicationId)) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.SERVICE_UNAVAILABLE, + Reason.SERVICE_UNAVAILABLE, "Application id is not available"); } if (this.cloudFoundrySecurityService == null) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.SERVICE_UNAVAILABLE, + Reason.SERVICE_UNAVAILABLE, "Cloud controller URL is not available"); } if (HttpMethod.OPTIONS.matches(request.getMethod())) { @@ -92,10 +97,10 @@ class CloudFoundrySecurityInterceptor { .getAccessLevel(token.toString(), this.applicationId); if (!accessLevel.isAccessAllowed(path)) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.ACCESS_DENIED, + Reason.ACCESS_DENIED, "Access denied"); } - accessLevel.put(request); + request.setAttribute(AccessLevel.REQUEST_ATTRIBUTE, accessLevel); } private Token getToken(HttpServletRequest request) { @@ -104,42 +109,10 @@ class CloudFoundrySecurityInterceptor { if (authorization == null || !authorization.toLowerCase().startsWith(bearerPrefix)) { throw new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.MISSING_AUTHORIZATION, + Reason.MISSING_AUTHORIZATION, "Authorization header is missing or invalid"); } return new Token(authorization.substring(bearerPrefix.length())); } - /** - * Response from the security interceptor. - */ - static class SecurityResponse { - - private final HttpStatus status; - - private final String message; - - SecurityResponse(HttpStatus status) { - this(status, null); - } - - SecurityResponse(HttpStatus status, String message) { - this.status = status; - this.message = message; - } - - public HttpStatus getStatus() { - return this.status; - } - - public String getMessage() { - return this.message; - } - - static SecurityResponse success() { - return new SecurityResponse(HttpStatus.OK); - } - - } - } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityService.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityService.java similarity index 96% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityService.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityService.java index 1f4efb8ea7..0188d84e63 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityService.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityService.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.net.URI; import java.net.URISyntaxException; @@ -22,6 +22,8 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.http.HttpStatus; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java similarity index 93% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java index 5b673c32ee..ec95c8f02c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.lang.reflect.Method; import java.util.Arrays; @@ -31,6 +31,8 @@ import javax.servlet.http.HttpServletResponse; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.SecurityResponse; import org.springframework.boot.actuate.endpoint.EndpointInfo; import org.springframework.boot.actuate.endpoint.OperationInvoker; import org.springframework.boot.actuate.endpoint.ParameterMappingException; @@ -91,12 +93,12 @@ class CloudFoundryWebEndpointServletHandlerMapping @ResponseBody private Map> links(HttpServletRequest request, HttpServletResponse response) { - CloudFoundrySecurityInterceptor.SecurityResponse securityResponse = this.securityInterceptor + SecurityResponse securityResponse = this.securityInterceptor .preHandle(request, ""); if (!securityResponse.getStatus().equals(HttpStatus.OK)) { sendFailureResponse(response, securityResponse); } - AccessLevel accessLevel = AccessLevel.get(request); + AccessLevel accessLevel = (AccessLevel) request.getAttribute(AccessLevel.REQUEST_ATTRIBUTE); Map links = this.endpointLinksResolver.resolveLinks(getEndpoints(), request.getRequestURL().toString()); Map filteredLinks = new LinkedHashMap<>(); @@ -111,7 +113,7 @@ class CloudFoundryWebEndpointServletHandlerMapping } private void sendFailureResponse(HttpServletResponse response, - CloudFoundrySecurityInterceptor.SecurityResponse securityResponse) { + SecurityResponse securityResponse) { try { response.sendError(securityResponse.getStatus().value(), securityResponse.getMessage()); @@ -151,7 +153,7 @@ class CloudFoundryWebEndpointServletHandlerMapping @ResponseBody public Object handle(HttpServletRequest request, @RequestBody(required = false) Map body) { - CloudFoundrySecurityInterceptor.SecurityResponse securityResponse = this.securityInterceptor + SecurityResponse securityResponse = this.securityInterceptor .preHandle(request, this.endpointId); if (!securityResponse.getStatus().equals(HttpStatus.OK)) { return failureResponse(securityResponse); @@ -173,7 +175,7 @@ class CloudFoundryWebEndpointServletHandlerMapping } private Object failureResponse( - CloudFoundrySecurityInterceptor.SecurityResponse response) { + SecurityResponse response) { return handleResult(new WebEndpointResponse<>(response.getMessage(), response.getStatus().value())); } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactory.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactory.java similarity index 99% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactory.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactory.java index b76b476ec0..82cc525f2c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactory.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactory.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.io.IOException; import java.net.HttpURLConnection; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidator.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidator.java similarity index 93% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidator.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidator.java index 7a9fffd4aa..f183e7ea0e 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidator.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidator.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.security.GeneralSecurityException; import java.security.KeyFactory; @@ -26,7 +26,9 @@ import java.security.spec.X509EncodedKeySpec; import java.util.Map; import java.util.concurrent.TimeUnit; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; import org.springframework.util.Base64Utils; /** @@ -34,13 +36,13 @@ import org.springframework.util.Base64Utils; * * @author Madhura Bhave */ -class TokenValidator { +public class TokenValidator { private final CloudFoundrySecurityService securityService; private Map tokenKeys; - TokenValidator(CloudFoundrySecurityService cloudFoundrySecurityService) { + public TokenValidator(CloudFoundrySecurityService cloudFoundrySecurityService) { this.securityService = cloudFoundrySecurityService; } diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories index fb947772a0..9f7c1837e2 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/main/resources/META-INF/spring.factories @@ -4,7 +4,8 @@ org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.audit.AuditEventsEndpointAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.beans.BeansEndpointAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.cassandra.CassandraHealthIndicatorAutoConfiguration,\ -org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryActuatorAutoConfiguration,\ +org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet.CloudFoundryActuatorAutoConfiguration,\ +org.springframework.boot.actuate.autoconfigure.cloudfoundry.reactive.ReactiveCloudFoundryActuatorAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.condition.AutoConfigurationReportEndpointAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.context.properties.ConfigurationPropertiesReportEndpointAutoConfiguration,\ org.springframework.boot.actuate.autoconfigure.context.ShutdownEndpointAutoConfiguration,\ diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AuthorizationExceptionMatcher.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AuthorizationExceptionMatcher.java index 1ca01e0e9a..21a3e70f03 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AuthorizationExceptionMatcher.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/AuthorizationExceptionMatcher.java @@ -26,12 +26,12 @@ import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryA * * @author Madhura Bhave */ -final class AuthorizationExceptionMatcher { +public final class AuthorizationExceptionMatcher { private AuthorizationExceptionMatcher() { } - static Matcher withReason(final Reason reason) { + public static Matcher withReason(final Reason reason) { return new CustomMatcher( "CloudFoundryAuthorizationException with " + reason + " reason") { diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java new file mode 100644 index 0000000000..d20f5f81a6 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/CloudFoundryWebFluxEndpointIntegrationTests.java @@ -0,0 +1,338 @@ +/* + * 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.cloudfoundry.reactive; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Consumer; + +import org.junit.Test; +import org.mockito.BDDMockito; +import reactor.core.publisher.Mono; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.endpoint.ParameterMapper; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.annotation.Selector; +import org.springframework.boot.actuate.endpoint.annotation.WriteOperation; +import org.springframework.boot.actuate.endpoint.cache.CachingConfiguration; +import org.springframework.boot.actuate.endpoint.convert.ConversionServiceParameterMapper; +import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; +import org.springframework.boot.actuate.endpoint.web.annotation.WebAnnotationEndpointDiscoverer; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.boot.web.embedded.netty.NettyReactiveWebServerFactory; +import org.springframework.boot.web.reactive.context.ReactiveWebServerApplicationContext; +import org.springframework.boot.web.reactive.context.ReactiveWebServerInitializedEvent; +import org.springframework.context.ApplicationContext; +import org.springframework.context.ApplicationListener; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.core.convert.support.DefaultConversionService; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.HttpHandler; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.util.Base64Utils; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.config.EnableWebFlux; +import org.springframework.web.server.adapter.WebHttpHandlerBuilder; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link CloudFoundryWebFluxEndpointHandlerMapping}. + * + * @author Madhura Bhave + */ +public class CloudFoundryWebFluxEndpointIntegrationTests { + + private static ReactiveTokenValidator tokenValidator = mock(ReactiveTokenValidator.class); + + private static ReactiveCloudFoundrySecurityService securityService = mock( + ReactiveCloudFoundrySecurityService.class); + + @Test + public void operationWithSecurityInterceptorForbidden() throws Exception { + given(tokenValidator.validate(any())).willReturn(Mono.empty()); + given(securityService.getAccessLevel(any(), eq("app-id"))) + .willReturn(Mono.just(AccessLevel.RESTRICTED)); + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/cfApplication/test") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()).exchange() + .expectStatus().isEqualTo(HttpStatus.FORBIDDEN)); + } + + @Test + public void operationWithSecurityInterceptorSuccess() throws Exception { + given(tokenValidator.validate(any())).willReturn(Mono.empty()); + given(securityService.getAccessLevel(any(), eq("app-id"))) + .willReturn(Mono.just(AccessLevel.FULL)); + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/cfApplication/test") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()).exchange() + .expectStatus().isEqualTo(HttpStatus.OK)); + } + + @Test + public void responseToOptionsRequestIncludesCorsHeaders() { + load(TestEndpointConfiguration.class, + (client) -> client.options().uri("/cfApplication/test") + .accept(MediaType.APPLICATION_JSON) + .header("Access-Control-Request-Method", "POST") + .header("Origin", "http://example.com").exchange().expectStatus() + .isOk().expectHeader() + .valueEquals("Access-Control-Allow-Origin", "http://example.com") + .expectHeader() + .valueEquals("Access-Control-Allow-Methods", "GET,POST")); + } + + @Test + public void linksToOtherEndpointsWithFullAccess() { + given(tokenValidator.validate(any())).willReturn(Mono.empty()); + given(securityService.getAccessLevel(any(), eq("app-id"))) + .willReturn(Mono.just(AccessLevel.FULL)); + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/cfApplication") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()).exchange() + .expectStatus().isOk().expectBody().jsonPath("_links.length()") + .isEqualTo(5).jsonPath("_links.self.href").isNotEmpty() + .jsonPath("_links.self.templated").isEqualTo(false) + .jsonPath("_links.info.href").isNotEmpty() + .jsonPath("_links.info.templated").isEqualTo(false) + .jsonPath("_links.env.href").isNotEmpty() + .jsonPath("_links.env.templated").isEqualTo(false) + .jsonPath("_links.test.href").isNotEmpty() + .jsonPath("_links.test.templated").isEqualTo(false) + .jsonPath("_links.test-part.href").isNotEmpty() + .jsonPath("_links.test-part.templated").isEqualTo(true)); + } + + @Test + public void linksToOtherEndpointsForbidden() { + CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException( + Reason.INVALID_TOKEN, "invalid-token"); + BDDMockito.willThrow(exception).given(tokenValidator).validate(any()); + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/cfApplication") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()).exchange() + .expectStatus().isUnauthorized()); + } + + @Test + public void linksToOtherEndpointsWithRestrictedAccess() { + given(tokenValidator.validate(any())).willReturn(Mono.empty()); + given(securityService.getAccessLevel(any(), eq("app-id"))) + .willReturn(Mono.just(AccessLevel.RESTRICTED)); + load(TestEndpointConfiguration.class, + (client) -> client.get().uri("/cfApplication") + .accept(MediaType.APPLICATION_JSON) + .header("Authorization", "bearer " + mockAccessToken()).exchange() + .expectStatus().isOk().expectBody().jsonPath("_links.length()") + .isEqualTo(2).jsonPath("_links.self.href").isNotEmpty() + .jsonPath("_links.self.templated").isEqualTo(false) + .jsonPath("_links.info.href").isNotEmpty() + .jsonPath("_links.info.templated").isEqualTo(false) + .jsonPath("_links.env").doesNotExist().jsonPath("_links.test") + .doesNotExist().jsonPath("_links.test-part").doesNotExist()); + } + + private ReactiveWebServerApplicationContext createApplicationContext( + Class... config) { + ReactiveWebServerApplicationContext context = new ReactiveWebServerApplicationContext(); + context.register(config); + return context; + } + + protected int getPort(ReactiveWebServerApplicationContext context) { + return context.getBean(CloudFoundryReactiveConfiguration.class).port; + } + + private void load(Class configuration, Consumer clientConsumer) { + BiConsumer consumer = (context, + client) -> clientConsumer.accept(client); + ReactiveWebServerApplicationContext context = createApplicationContext( + configuration, CloudFoundryReactiveConfiguration.class); + context.refresh(); + try { + consumer.accept(context, WebTestClient.bindToServer() + .baseUrl("http://localhost:" + getPort(context)).build()); + } + finally { + context.close(); + } + } + + private String mockAccessToken() { + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu" + + "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + + Base64Utils.encodeToString("signature".getBytes()); + } + + @Configuration + @EnableWebFlux + static class CloudFoundryReactiveConfiguration { + + private int port; + + @Bean + public ReactiveCloudFoundrySecurityInterceptor interceptor() { + return new ReactiveCloudFoundrySecurityInterceptor(tokenValidator, securityService, + "app-id"); + } + + @Bean + public EndpointMediaTypes EndpointMediaTypes() { + return new EndpointMediaTypes(Collections.singletonList("application/json"), + Collections.singletonList("application/json")); + } + + @Bean + public CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebEndpointServletHandlerMapping( + WebAnnotationEndpointDiscoverer webEndpointDiscoverer, + EndpointMediaTypes endpointMediaTypes, + ReactiveCloudFoundrySecurityInterceptor interceptor) { + CorsConfiguration corsConfiguration = new CorsConfiguration(); + corsConfiguration.setAllowedOrigins(Arrays.asList("http://example.com")); + corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST")); + return new CloudFoundryWebFluxEndpointHandlerMapping( + new EndpointMapping("/cfApplication"), + webEndpointDiscoverer.discoverEndpoints(), endpointMediaTypes, + corsConfiguration, interceptor); + } + + @Bean + public WebAnnotationEndpointDiscoverer webEndpointDiscoverer( + ApplicationContext applicationContext, + EndpointMediaTypes endpointMediaTypes) { + ParameterMapper parameterMapper = new ConversionServiceParameterMapper( + DefaultConversionService.getSharedInstance()); + return new WebAnnotationEndpointDiscoverer(applicationContext, + parameterMapper, (id) -> new CachingConfiguration(0), + endpointMediaTypes, (id) -> id); + } + + @Bean + public EndpointDelegate endpointDelegate() { + return mock(EndpointDelegate.class); + } + + @Bean + public NettyReactiveWebServerFactory netty() { + return new NettyReactiveWebServerFactory(0); + } + + @Bean + public HttpHandler httpHandler(ApplicationContext applicationContext) { + return WebHttpHandlerBuilder.applicationContext(applicationContext).build(); + } + + @Bean + public ApplicationListener serverInitializedListener() { + return (event) -> this.port = event.getWebServer().getPort(); + } + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + private final EndpointDelegate endpointDelegate; + + TestEndpoint(EndpointDelegate endpointDelegate) { + this.endpointDelegate = endpointDelegate; + } + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + @ReadOperation + public Map readPart(@Selector String part) { + return Collections.singletonMap("part", part); + } + + @WriteOperation + public void write(String foo, String bar) { + this.endpointDelegate.write(foo, bar); + } + + } + + @Endpoint(id = "env") + static class TestEnvEndpoint { + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + } + + @Endpoint(id = "info") + static class TestInfoEndpoint { + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + } + + @Configuration + @Import(CloudFoundryReactiveConfiguration.class) + protected static class TestEndpointConfiguration { + + @Bean + public TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) { + return new TestEndpoint(endpointDelegate); + } + + @Bean + public TestInfoEndpoint testInfoEnvEndpoint() { + return new TestInfoEndpoint(); + } + + @Bean + public TestEnvEndpoint testEnvEndpoint() { + return new TestEnvEndpoint(); + } + + } + + public interface EndpointDelegate { + + void write(); + + void write(String foo, String bar); + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java new file mode 100644 index 0000000000..0ee4e385a1 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundryActuatorAutoConfigurationTests.java @@ -0,0 +1,268 @@ +/* + * 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.cloudfoundry.reactive; + +import java.util.Arrays; +import java.util.List; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration; +import org.springframework.boot.actuate.endpoint.EndpointInfo; +import org.springframework.boot.actuate.endpoint.annotation.Endpoint; +import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; +import org.springframework.boot.actuate.endpoint.http.ActuatorMediaType; +import org.springframework.boot.actuate.endpoint.web.WebEndpointOperation; +import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration; +import org.springframework.boot.autoconfigure.http.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.security.reactive.ReactiveSecurityAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.WebFluxAutoConfiguration; +import org.springframework.boot.autoconfigure.web.reactive.function.client.WebClientAutoConfiguration; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.boot.test.util.TestPropertyValues; +import org.springframework.boot.web.reactive.context.GenericReactiveWebApplicationContext; +import org.springframework.boot.web.reactive.function.client.WebClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.web.server.SecurityWebFilterChain; +import org.springframework.security.web.server.WebFilterChainProxy; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.cors.CorsConfiguration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +/** + * Tests for {@link ReactiveCloudFoundryActuatorAutoConfiguration}. + * + * @author Madhura Bhave + */ +public class ReactiveCloudFoundryActuatorAutoConfigurationTests { + + private GenericReactiveWebApplicationContext context; + + @Before + public void setup() { + this.context = new GenericReactiveWebApplicationContext(); + } + + @After + public void close() { + if (this.context != null) { + this.context.close(); + } + } + + @Test + public void cloudFoundryPlatformActive() throws Exception { + setupContextWithCloudEnabled(); + this.context.refresh(); + CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(); + EndpointMapping endpointMapping = (EndpointMapping) ReflectionTestUtils.getField(handlerMapping, "endpointMapping"); + assertThat(endpointMapping.getPath()) + .isEqualTo("/cloudfoundryapplication"); + CorsConfiguration corsConfiguration = (CorsConfiguration) ReflectionTestUtils + .getField(handlerMapping, "corsConfiguration"); + assertThat(corsConfiguration.getAllowedOrigins()).contains("*"); + assertThat(corsConfiguration.getAllowedMethods()).containsAll( + Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name())); + assertThat(corsConfiguration.getAllowedHeaders()).containsAll( + Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type")); + } + + @Test + public void cloudfoundryapplicationProducesActuatorMediaType() throws Exception { + setupContextWithCloudEnabled(); + this.context.refresh(); + WebTestClient webTestClient = WebTestClient.bindToApplicationContext(this.context).build(); + webTestClient.get().uri("/cloudfoundryapplication") + .header("Content-Type", ActuatorMediaType.V2_JSON + ";charset=UTF-8"); + } + + @Test + public void cloudFoundryPlatformActiveSetsApplicationId() throws Exception { + setupContextWithCloudEnabled(); + this.context.refresh(); + CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(); + Object interceptor = ReflectionTestUtils.getField(handlerMapping, + "securityInterceptor"); + String applicationId = (String) ReflectionTestUtils.getField(interceptor, + "applicationId"); + assertThat(applicationId).isEqualTo("my-app-id"); + } + + @Test + public void cloudFoundryPlatformActiveSetsCloudControllerUrl() throws Exception { + setupContextWithCloudEnabled(); + this.context.refresh(); + CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(); + Object interceptor = ReflectionTestUtils.getField(handlerMapping, + "securityInterceptor"); + Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor, + "cloudFoundrySecurityService"); + String cloudControllerUrl = (String) ReflectionTestUtils + .getField(interceptorSecurityService, "cloudControllerUrl"); + assertThat(cloudControllerUrl).isEqualTo("http://my-cloud-controller.com"); + } + + @Test + public void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() + throws Exception { + TestPropertyValues + .of("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id") + .applyTo(this.context); + setupContext(); + this.context.refresh(); + CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = this.context.getBean("cloudFoundryWebFluxEndpointHandlerMapping", + CloudFoundryWebFluxEndpointHandlerMapping.class); + Object securityInterceptor = ReflectionTestUtils.getField(handlerMapping, + "securityInterceptor"); + Object interceptorSecurityService = ReflectionTestUtils + .getField(securityInterceptor, "cloudFoundrySecurityService"); + assertThat(interceptorSecurityService).isNull(); + } + + @Test + public void cloudFoundryPathsIgnoredBySpringSecurity() throws Exception { + setupContextWithCloudEnabled(); + this.context.refresh(); + WebFilterChainProxy chainProxy = this.context + .getBean(WebFilterChainProxy.class); + List filters = (List) ReflectionTestUtils.getField(chainProxy, "filters"); + Boolean cfRequestMatches = filters.get(0).matches(MockServerWebExchange.from( + MockServerHttpRequest.get("/cloudfoundryapplication/my-path").build())).block(); + Boolean otherRequestMatches = filters.get(0).matches(MockServerWebExchange.from( + MockServerHttpRequest.get("/some-other-path").build())).block(); + assertThat(cfRequestMatches).isTrue(); + assertThat(otherRequestMatches).isFalse(); + otherRequestMatches = filters.get(1).matches(MockServerWebExchange.from( + MockServerHttpRequest.get("/some-other-path").build())).block(); + assertThat(otherRequestMatches).isTrue(); + } + + @Test + public void cloudFoundryPlatformInactive() throws Exception { + setupContext(); + this.context.refresh(); + assertThat( + this.context.containsBean("cloudFoundryWebFluxEndpointHandlerMapping")) + .isFalse(); + } + + @Test + public void cloudFoundryManagementEndpointsDisabled() throws Exception { + setupContextWithCloudEnabled(); + TestPropertyValues + .of("VCAP_APPLICATION=---", "management.cloudfoundry.enabled:false") + .applyTo(this.context); + this.context.refresh(); + assertThat(this.context.containsBean("cloudFoundryWebFluxEndpointHandlerMapping")) + .isFalse(); + } + + @Test + public void allEndpointsAvailableUnderCloudFoundryWithoutEnablingWeb() + throws Exception { + setupContextWithCloudEnabled(); + this.context.register(TestConfiguration.class); + this.context.refresh(); + CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(); + List> endpoints = (List>) handlerMapping + .getEndpoints(); + assertThat(endpoints.size()).isEqualTo(1); + assertThat(endpoints.get(0).getId()).isEqualTo("test"); + } + + @Test + public void endpointPathCustomizationIsNotApplied() + throws Exception { + setupContextWithCloudEnabled(); + this.context.register(TestConfiguration.class); + this.context.refresh(); + CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(); + List> endpoints = (List>) handlerMapping + .getEndpoints(); + assertThat(endpoints.size()).isEqualTo(1); + assertThat(endpoints.get(0).getOperations()).hasSize(1); + assertThat(endpoints.get(0).getOperations().iterator().next() + .getRequestPredicate().getPath()).isEqualTo("test"); + } + + private void setupContextWithCloudEnabled() { + TestPropertyValues + .of("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id", + "vcap.application.cf_api:http://my-cloud-controller.com") + .applyTo(this.context); + setupContext(); + } + + private void setupContext() { + this.context.register(ReactiveSecurityAutoConfiguration.class, + WebFluxAutoConfiguration.class, JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + PropertyPlaceholderAutoConfiguration.class, + WebClientCustomizerConfig.class, + WebClientAutoConfiguration.class, + ManagementContextAutoConfiguration.class, + EndpointAutoConfiguration.class, + ReactiveCloudFoundryActuatorAutoConfiguration.class); + } + + private CloudFoundryWebFluxEndpointHandlerMapping getHandlerMapping() { + return this.context.getBean("cloudFoundryWebFluxEndpointHandlerMapping", + CloudFoundryWebFluxEndpointHandlerMapping.class); + } + + @Configuration + static class TestConfiguration { + + @Bean + public TestEndpoint testEndpoint() { + return new TestEndpoint(); + } + + } + + @Endpoint(id = "test") + static class TestEndpoint { + + @ReadOperation + public String hello() { + return "hello world"; + } + + } + + @Configuration + static class WebClientCustomizerConfig { + + @Bean + public WebClientCustomizer webClientCustomizer() { + return mock(WebClientCustomizer.class); + } + + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityInterceptorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityInterceptorTests.java new file mode 100644 index 0000000000..a93a33f215 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityInterceptorTests.java @@ -0,0 +1,187 @@ +/* + * 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.cloudfoundry.reactive; + +import org.junit.Before; +import org.junit.Test; +import org.mockito.BDDMockito; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.util.Base64Utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; + +/** + * Tests for {@link ReactiveCloudFoundrySecurityInterceptor}. + * + * @author Madhura Bhave + */ +public class ReactiveCloudFoundrySecurityInterceptorTests { + + @Mock + private ReactiveTokenValidator tokenValidator; + + @Mock + private ReactiveCloudFoundrySecurityService securityService; + + private ReactiveCloudFoundrySecurityInterceptor interceptor; + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + this.interceptor = new ReactiveCloudFoundrySecurityInterceptor(this.tokenValidator, + this.securityService, "my-app-id"); + } + + @Test + public void preHandleWhenRequestIsPreFlightShouldBeOk() throws Exception { + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.options("/a") + .header(HttpHeaders.ORIGIN, "http://example.com") + .header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET") + .build()); + StepVerifier.create(this.interceptor.preHandle(request, "/a")) + .consumeNextWith(response -> assertThat(response.getStatus()).isEqualTo(HttpStatus.OK)) + .verifyComplete(); + } + + @Test + public void preHandleWhenTokenIsMissingShouldReturnMissingAuthorization() throws Exception { + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("/a") + .build()); + StepVerifier.create(this.interceptor.preHandle(request, "/a")) + .consumeNextWith(response -> assertThat(response.getStatus()) + .isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus())) + .verifyComplete(); + } + + @Test + public void preHandleWhenTokenIsNotBearerShouldReturnMissingAuthorization() throws Exception { + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("/a") + .header(HttpHeaders.AUTHORIZATION, mockAccessToken()) + .build()); + StepVerifier.create(this.interceptor.preHandle(request, "/a")) + .consumeNextWith(response -> assertThat(response.getStatus()) + .isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus())) + .verifyComplete(); + } + + @Test + public void preHandleWhenApplicationIdIsNullShouldReturnError() throws Exception { + this.interceptor = new ReactiveCloudFoundrySecurityInterceptor(this.tokenValidator, + this.securityService, null); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("/a") + .header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken()) + .build()); + StepVerifier.create(this.interceptor.preHandle(request, "/a")) + .consumeErrorWith(throwable -> assertThat(((CloudFoundryAuthorizationException) throwable).getReason()) + .isEqualTo(Reason.SERVICE_UNAVAILABLE)) + .verify(); + } + + @Test + public void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnError() + throws Exception { + this.interceptor = new ReactiveCloudFoundrySecurityInterceptor(this.tokenValidator, null, + "my-app-id"); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("/a") + .header(HttpHeaders.AUTHORIZATION, mockAccessToken()) + .build()); + StepVerifier.create(this.interceptor.preHandle(request, "/a")) + .consumeErrorWith(throwable -> assertThat(((CloudFoundryAuthorizationException) throwable).getReason()) + .isEqualTo(Reason.SERVICE_UNAVAILABLE)) + .verify(); + } + + @Test + public void preHandleWhenAccessIsNotAllowedShouldReturnAccessDenied() throws Exception { + BDDMockito.given(this.securityService.getAccessLevel(mockAccessToken(), "my-app-id")) + .willReturn(Mono.just(AccessLevel.RESTRICTED)); + BDDMockito.given(this.tokenValidator.validate(any())) + .willReturn(Mono.empty()); + MockServerWebExchange request = MockServerWebExchange + .from(MockServerHttpRequest.get("/a") + .header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken()) + .build()); + StepVerifier.create(this.interceptor.preHandle(request, "/a")) + .consumeNextWith(response -> { + assertThat(response.getStatus()) + .isEqualTo(Reason.ACCESS_DENIED.getStatus()); + }) + .verifyComplete(); + } + + @Test + public void preHandleSuccessfulWithFullAccess() throws Exception { + String accessToken = mockAccessToken(); + BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id")) + .willReturn(Mono.just(AccessLevel.FULL)); + BDDMockito.given(this.tokenValidator.validate(any())) + .willReturn(Mono.empty()); + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/a") + .header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken()) + .build()); + StepVerifier.create(this.interceptor.preHandle(exchange, "/a")) + .consumeNextWith(response -> { + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK); + assertThat((AccessLevel) exchange.getAttribute("cloudFoundryAccessLevel")) + .isEqualTo(AccessLevel.FULL); + }).verifyComplete(); + } + + @Test + public void preHandleSuccessfulWithRestrictedAccess() throws Exception { + String accessToken = mockAccessToken(); + BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id")) + .willReturn(Mono.just(AccessLevel.RESTRICTED)); + BDDMockito.given(this.tokenValidator.validate(any())) + .willReturn(Mono.empty()); + MockServerWebExchange exchange = MockServerWebExchange + .from(MockServerHttpRequest.get("/info") + .header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken()) + .build()); + StepVerifier.create(this.interceptor.preHandle(exchange, "info")) + .consumeNextWith(response -> { + assertThat(response.getStatus()).isEqualTo(HttpStatus.OK); + assertThat((AccessLevel) exchange.getAttribute("cloudFoundryAccessLevel")) + .isEqualTo(AccessLevel.RESTRICTED); + }).verifyComplete(); + } + + private String mockAccessToken() { + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu" + + "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + + Base64Utils.encodeToString("signature".getBytes()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java new file mode 100644 index 0000000000..d93d6e9865 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveCloudFoundrySecurityServiceTests.java @@ -0,0 +1,263 @@ +/* + * 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.cloudfoundry.reactive; + +import java.util.function.Consumer; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; +import okhttp3.mockwebserver.RecordedRequest; +import org.junit.After; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import reactor.test.StepVerifier; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.http.HttpHeaders; +import org.springframework.web.reactive.function.client.WebClient; + +import static org.assertj.core.api.Assertions.assertThat; + +/** + * Tests for {@link ReactiveCloudFoundrySecurityService}. + * + * @author Madhura Bhave + */ +public class ReactiveCloudFoundrySecurityServiceTests { + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + private static final String CLOUD_CONTROLLER = "/my-cloud-controller.com"; + + private static final String CLOUD_CONTROLLER_PERMISSIONS = CLOUD_CONTROLLER + + "/v2/apps/my-app-id/permissions"; + + private static final String UAA_URL = "http://my-cloud-controller.com/uaa"; + + private ReactiveCloudFoundrySecurityService securityService; + + private MockWebServer server; + + private WebClient.Builder builder; + + @Before + public void setup() throws Exception { + this.server = new MockWebServer(); + this.builder = WebClient.builder().baseUrl(this.server.url("/").toString()); + this.securityService = new ReactiveCloudFoundrySecurityService(this.builder, CLOUD_CONTROLLER); + } + + @After + public void shutdown() throws Exception { + this.server.shutdown(); + } + + @Test + public void getAccessLevelWhenSpaceDeveloperShouldReturnFull() throws Exception { + String responseBody = "{\"read_sensitive_data\": true,\"read_basic_data\": true}"; + prepareResponse(response -> response.setBody(responseBody) + .setHeader("Content-Type", "application/json")); + StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id")) + .consumeNextWith( + accessLevel -> assertThat(accessLevel).isEqualTo(AccessLevel.FULL)) + .expectComplete().verify(); + expectRequest(request -> { + assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token"); + assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS); + }); + } + + @Test + public void getAccessLevelWhenNotSpaceDeveloperShouldReturnRestricted() + throws Exception { + String responseBody = "{\"read_sensitive_data\": false,\"read_basic_data\": true}"; + prepareResponse(response -> response.setBody(responseBody) + .setHeader("Content-Type", "application/json")); + StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id")) + .consumeNextWith( + accessLevel -> assertThat(accessLevel).isEqualTo(AccessLevel.RESTRICTED)) + .expectComplete().verify(); + expectRequest(request -> { + assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token"); + assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS); + }); + } + + @Test + public void getAccessLevelWhenTokenIsNotValidShouldThrowException() throws Exception { + prepareResponse(response -> response.setResponseCode(401)); + StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id")) + .consumeErrorWith( + throwable -> { + assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable).getReason()).isEqualTo(Reason.INVALID_TOKEN); + }) + .verify(); + expectRequest(request -> { + assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token"); + assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS); + }); + } + + @Test + public void getAccessLevelWhenForbiddenShouldThrowException() throws Exception { + prepareResponse(response -> response.setResponseCode(403)); + StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id")) + .consumeErrorWith( + throwable -> { + assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable).getReason()).isEqualTo(Reason.ACCESS_DENIED); + }) + .verify(); + expectRequest(request -> { + assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token"); + assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS); + }); + } + + @Test + public void getAccessLevelWhenCloudControllerIsNotReachableThrowsException() + throws Exception { + prepareResponse(response -> response.setResponseCode(500)); + StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id")) + .consumeErrorWith( + throwable -> { + assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable).getReason()).isEqualTo(Reason.SERVICE_UNAVAILABLE); + }) + .verify(); + expectRequest(request -> { + assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token"); + assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS); + }); + } + + @Test + public void fetchTokenKeysWhenSuccessfulShouldReturnListOfKeysFromUAA() + throws Exception { + String tokenKeyValue = "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO\n" + + "rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7\n" + + "fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB\n" + + "LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO\n" + + "kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo\n" + + "jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI\n" + + "JwIDAQAB\n-----END PUBLIC KEY-----"; + prepareResponse(response -> { + response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}"); + response.setHeader("Content-Type", "application/json"); + }); + String responseBody = "{\"keys\" : [ {\"kid\":\"test-key\",\"value\" : \"" + + tokenKeyValue.replace("\n", "\\n") + "\"} ]}"; + prepareResponse(response -> { + response.setBody(responseBody); + response.setHeader("Content-Type", "application/json"); + }); + StepVerifier.create(this.securityService.fetchTokenKeys()) + .consumeNextWith( + tokenKeys -> assertThat(tokenKeys.get("test-key")).isEqualTo(tokenKeyValue)) + .expectComplete().verify(); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info")); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys")); + } + + @Test + public void fetchTokenKeysWhenNoKeysReturnedFromUAA() throws Exception { + prepareResponse(response -> { + response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}"); + response.setHeader("Content-Type", "application/json"); + }); + String responseBody = "{\"keys\": []}"; + prepareResponse(response -> { + response.setBody(responseBody); + response.setHeader("Content-Type", "application/json"); + }); + StepVerifier.create(this.securityService.fetchTokenKeys()) + .consumeNextWith( + tokenKeys -> assertThat(tokenKeys).hasSize(0)) + .expectComplete().verify(); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info")); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys")); + } + + @Test + public void fetchTokenKeysWhenUnsuccessfulShouldThrowException() throws Exception { + prepareResponse(response -> { + response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}"); + response.setHeader("Content-Type", "application/json"); + }); + prepareResponse(response -> { + response.setResponseCode(500); + }); + StepVerifier.create(this.securityService.fetchTokenKeys()) + .consumeErrorWith( + throwable -> assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.SERVICE_UNAVAILABLE)) + .verify(); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info")); + expectRequest(request -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys")); + } + + @Test + public void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() throws Exception { + prepareResponse(response -> { + response.setBody("{\"token_endpoint\":\"" + UAA_URL + "\"}"); + response.setHeader("Content-Type", "application/json"); + }); + StepVerifier.create(this.securityService.getUaaUrl()) + .consumeNextWith( + uaaUrl -> assertThat(uaaUrl).isEqualTo(UAA_URL)) + .expectComplete().verify(); + //this.securityService.getUaaUrl().block(); //FIXME subscribe again to check that it isn't called again + expectRequest(request -> assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER + "/info")); + expectRequestCount(1); + } + + @Test + public void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException() + throws Exception { + prepareResponse(response -> response.setResponseCode(500)); + StepVerifier.create(this.securityService.getUaaUrl()) + .consumeErrorWith( + throwable -> { + assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable).getReason()).isEqualTo(Reason.SERVICE_UNAVAILABLE); + }) + .verify(); + expectRequest(request -> assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER + "/info")); + } + + private void prepareResponse(Consumer consumer) { + MockResponse response = new MockResponse(); + consumer.accept(response); + this.server.enqueue(response); + } + + private void expectRequest(Consumer consumer) throws InterruptedException { + consumer.accept(this.server.takeRequest()); + } + + private void expectRequestCount(int count) { + assertThat(count).isEqualTo(this.server.getRequestCount()); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidatorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidatorTests.java new file mode 100644 index 0000000000..017346d258 --- /dev/null +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/reactive/ReactiveTokenValidatorTests.java @@ -0,0 +1,260 @@ +/* + * 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.cloudfoundry.reactive; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.nio.charset.Charset; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.Signature; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.Collections; +import java.util.Map; + +import org.apache.commons.codec.binary.Base64; +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.ExpectedException; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; +import org.springframework.util.Base64Utils; +import org.springframework.util.StreamUtils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +/** + * Tests for {@link ReactiveTokenValidator}. + * + * @author Madhura Bhave + */ +public class ReactiveTokenValidatorTests { + + private static final byte[] DOT = ".".getBytes(); + + private static final Charset UTF_8 = Charset.forName("UTF-8"); + + @Rule + public ExpectedException thrown = ExpectedException.none(); + + @Mock + private ReactiveCloudFoundrySecurityService securityService; + + private ReactiveTokenValidator tokenValidator; + + private static final String VALID_KEY = "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO\n" + + "rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7\n" + + "fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB\n" + + "LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO\n" + + "kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo\n" + + "jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI\n" + + "JwIDAQAB\n-----END PUBLIC KEY-----"; + + private static final String INVALID_KEY = "-----BEGIN PUBLIC KEY-----\n" + + "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK\n" + + "5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa\n" + + "vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0\n" + + "FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC\n" + + "VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M\n" + + "r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s\n" + + "YwIDAQAB\n-----END PUBLIC KEY-----"; + + private static final Map INVALID_KEYS = Collections + .singletonMap("invalid-key", INVALID_KEY); + + private static final Map VALID_KEYS = Collections + .singletonMap("valid-key", VALID_KEY); + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + this.tokenValidator = new ReactiveTokenValidator(this.securityService); + } + + @Test + public void validateTokenWhenKidValidationFailsShouldThrowException() + throws Exception { + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(INVALID_KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa")); + String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}"; + String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).consumeErrorWith(throwable -> { + assertThat(throwable).isExactlyInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.INVALID_KEY_ID); + }).verify(); + } + + @Test + public void validateTokenWhenKidValidationSucceeds() + throws Exception { + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa")); + String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}"; + String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).verifyComplete(); + } + + @Test + public void validateTokenWhenSignatureInvalidShouldThrowException() throws Exception { + Map KEYS = Collections + .singletonMap("valid-key", INVALID_KEY); + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa")); + String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}"; + String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).consumeErrorWith(throwable -> { + assertThat(throwable).isExactlyInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.INVALID_SIGNATURE); + }).verify(); + } + + @Test + public void validateTokenWhenTokenAlgorithmIsNotRS256ShouldThrowException() + throws Exception { + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa")); + String header = "{ \"alg\": \"HS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}"; + String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).consumeErrorWith(throwable -> { + assertThat(throwable).isExactlyInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM); + }).verify(); + } + + @Test + public void validateTokenWhenExpiredShouldThrowException() throws Exception { + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa")); + String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}"; + String claims = "{ \"jti\": \"0236399c350c47f3ae77e67a75e75e7d\", \"exp\": 1477509977, \"scope\": [\"actuator.read\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).consumeErrorWith(throwable -> { + assertThat(throwable).isExactlyInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.TOKEN_EXPIRED); + }).verify(); + } + + @Test + public void validateTokenWhenIssuerIsNotValidShouldThrowException() throws Exception { + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://other-uaa.com")); + String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\", \"scope\": [\"actuator.read\"]}"; + String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).consumeErrorWith(throwable -> { + assertThat(throwable).isExactlyInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.INVALID_ISSUER); + }).verify(); + } + + @Test + public void validateTokenWhenAudienceIsNotValidShouldThrowException() + throws Exception { + given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS)); + given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa")); + String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}"; + String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}"; + StepVerifier.create(this.tokenValidator.validate( + new Token(getSignedToken(header.getBytes(), claims.getBytes())))).consumeErrorWith(throwable -> { + assertThat(throwable).isExactlyInstanceOf(CloudFoundryAuthorizationException.class); + assertThat(((CloudFoundryAuthorizationException) throwable) + .getReason()).isEqualTo(Reason.INVALID_AUDIENCE); + }).verify(); + } + + private String getSignedToken(byte[] header, byte[] claims) throws Exception { + PrivateKey privateKey = getPrivateKey(); + Signature signature = Signature.getInstance("SHA256WithRSA"); + signature.initSign(privateKey); + byte[] content = dotConcat(Base64Utils.encodeUrlSafe(header), + Base64Utils.encode(claims)); + signature.update(content); + byte[] crypto = signature.sign(); + byte[] token = dotConcat(Base64Utils.encodeUrlSafe(header), + Base64Utils.encodeUrlSafe(claims), Base64Utils.encodeUrlSafe(crypto)); + return new String(token, UTF_8); + } + + private PrivateKey getPrivateKey() + throws InvalidKeySpecException, NoSuchAlgorithmException { + String signingKey = "-----BEGIN PRIVATE KEY-----\n" + + "MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDSbn2Xa72IOcxu\n" + + "tcd+qQ6ufZ1VDe98EmpwO4VQrTd37U9kZtWU0KqeSkgnyzIWmlbyWOdbB4/v4uJa\n" + + "lGjPQjt9hvd3xOOFXzpj33sWXgMGvGAzopMk64T+7GegOFlDXguA5TZyReM7M51O\n" + + "ycYwpAEsKXS+lxcG0UsxpJum/WjOLyHsMnJVnoScVBlRYZ2BMyEOuap69/H3lT/X\n" + + "pzlYEM6SrAifsaWvL2f1K7HKBt/yDkDOlZy6xmAMsghnslNSV0FvypTZrQOXia8t\n" + + "k6fjA+iN+P0LDZAgKxzn4/B/bV8/6HN/7VZJEdudi/y5qdE7SBnx6QZqCEz/YfqC\n" + + "olujacgnAgMBAAECggEAc9X2tJ/OWWrXqinOg160gkELloJxTi8lAFsDbAGuAwpT\n" + + "JcWl1KF5CmGBjsY/8ElNi2J9GJL1HOwcBhikCVNARD1DhF6RkB13mvquWwWtTMvt\n" + + "eP8JWM19DIc+E+hw2rCuTGngqs7l4vTqpzBTNPtS2eiIJ1IsjsgvSEiAlk/wnW48\n" + + "11cf6SQMQcT3HNTWrS+yLycEuWKb6Khh8RpD9D+i8w2+IspWz5lTP7BrKCUNsLOx\n" + + "6+5T52HcaZ9z3wMnDqfqIKWl3h8M+q+HFQ4EN5BPWYV4fF7EOx7+Qf2fKDFPoTjC\n" + + "VTWzDRNAA1xPqwdF7IdPVOXCdaUJDOhHeXZGaTNSwQKBgQDxb9UiR/Jh1R3muL7I\n" + + "neIt1gXa0O+SK7NWYl4DkArYo7V81ztxI8r+xKEeu5zRZZkpaJHxOnd3VfADascw\n" + + "UfALvxGxN2z42lE6zdhrmxZ3ma+akQFsv7NyXcBT00sdW+xmOiCaAj0cgxNOXiV3\n" + + "sYOwUy3SqUIPO2obpb+KC5ALHwKBgQDfH+NSQ/jn89oVZ3lzUORa+Z+aL1TGsgzs\n" + + "p7IG0MTEYiR9/AExYUwJab0M4PDXhumeoACMfkCFALNVhpch2nXZv7X5445yRgfD\n" + + "ONY4WknecuA0rfCLTruNWnQ3RR+BXmd9jD/5igd9hEIawz3V+jCHvAtzI8/CZIBt\n" + + "AArBs5kp+QKBgQCdxwN1n6baIDemK10iJWtFoPO6h4fH8h8EeMwPb/ZmlLVpnA4Q\n" + + "Zd+mlkDkoJ5eiRKKaPfWuOqRZeuvj/wTq7g/NOIO+bWQ+rrSvuqLh5IrHpgPXmub\n" + + "8bsHJhUlspMH4KagN6ROgOAG3fGj6Qp7KdpxRCpR3KJ66czxvGNrhxre6QKBgB+s\n" + + "MCGiYnfSprd5G8VhyziazKwfYeJerfT+DQhopDXYVKPJnQW8cQW5C8wDNkzx6sHI\n" + + "pqtK1K/MnKhcVaHJmAcT7qoNQlA4Xqu4qrgPIQNBvU/dDRNJVthG6c5aspEzrG8m\n" + + "9IHgtRV9K8EOy/1O6YqrB9kNUVWf3JccdWpvqyNJAoGAORzJiQCOk4egbdcozDTo\n" + + "4Tg4qk/03qpTy5k64DxkX1nJHu8V/hsKwq9Af7Fj/iHy2Av54BLPlBaGPwMi2bzB\n" + + "gYjmUomvx/fqOTQks9Rc4PIMB43p6Rdj0sh+52SKPDR2eHbwsmpuQUXnAs20BPPI\n" + + "J/OOn5zOs8yf26os0q3+JUM=\n-----END PRIVATE KEY-----"; + String privateKey = signingKey.replace("-----BEGIN PRIVATE KEY-----\n", ""); + privateKey = privateKey.replace("-----END PRIVATE KEY-----", ""); + byte[] pkcs8EncodedBytes = Base64.decodeBase64(privateKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePrivate(keySpec); + } + + private byte[] dotConcat(byte[]... bytes) throws IOException { + ByteArrayOutputStream result = new ByteArrayOutputStream(); + for (int i = 0; i < bytes.length; i++) { + if (i > 0) { + StreamUtils.copy(DOT, result); + } + StreamUtils.copy(bytes[i], result); + } + return result.toByteArray(); + } + +} diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java similarity index 99% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java index 01f0e89089..9d77d3e16c 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.util.Arrays; import java.util.List; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java similarity index 96% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java index 09fbd90874..4947400382 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.util.Arrays; import java.util.Collections; @@ -24,6 +24,9 @@ import java.util.function.Consumer; import org.junit.Test; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; import org.springframework.boot.actuate.endpoint.ParameterMapper; import org.springframework.boot.actuate.endpoint.annotation.Endpoint; import org.springframework.boot.actuate.endpoint.annotation.ReadOperation; @@ -127,7 +130,7 @@ public class CloudFoundryMvcWebEndpointIntegrationTests { @Test public void linksToOtherEndpointsForbidden() { CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException( - CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, "invalid-token"); + Reason.INVALID_TOKEN, "invalid-token"); willThrow(exception).given(tokenValidator).validate(any()); load(TestEndpointConfiguration.class, (client) -> client.get().uri("/cfApplication") diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptorTests.java similarity index 90% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptorTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptorTests.java index 8ecb75725a..7c5cab8282 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptorTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptorTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import org.junit.Before; import org.junit.Test; @@ -22,7 +22,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.MockitoAnnotations; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.SecurityResponse; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.mock.web.MockHttpServletRequest; @@ -62,14 +65,14 @@ public class CloudFoundrySecurityInterceptorTests { this.request.setMethod("OPTIONS"); this.request.addHeader(HttpHeaders.ORIGIN, "http://example.com"); this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET"); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); assertThat(response.getStatus()).isEqualTo(HttpStatus.OK); } @Test public void preHandleWhenTokenIsMissingShouldReturnFalse() throws Exception { - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); assertThat(response.getStatus()) .isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus()); @@ -78,7 +81,7 @@ public class CloudFoundrySecurityInterceptorTests { @Test public void preHandleWhenTokenIsNotBearerShouldReturnFalse() throws Exception { this.request.addHeader("Authorization", mockAccessToken()); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); assertThat(response.getStatus()) .isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus()); @@ -89,7 +92,7 @@ public class CloudFoundrySecurityInterceptorTests { this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, null); this.request.addHeader("Authorization", "bearer " + mockAccessToken()); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); assertThat(response.getStatus()) .isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus()); @@ -101,7 +104,7 @@ public class CloudFoundrySecurityInterceptorTests { this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null, "my-app-id"); this.request.addHeader("Authorization", "bearer " + mockAccessToken()); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); assertThat(response.getStatus()) .isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus()); @@ -113,7 +116,7 @@ public class CloudFoundrySecurityInterceptorTests { this.request.addHeader("Authorization", "bearer " + accessToken); given(this.securityService.getAccessLevel(accessToken, "my-app-id")) .willReturn(AccessLevel.RESTRICTED); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); assertThat(response.getStatus()).isEqualTo(Reason.ACCESS_DENIED.getStatus()); } @@ -124,7 +127,7 @@ public class CloudFoundrySecurityInterceptorTests { this.request.addHeader("Authorization", "Bearer " + accessToken); given(this.securityService.getAccessLevel(accessToken, "my-app-id")) .willReturn(AccessLevel.FULL); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "/a"); ArgumentCaptor tokenArgumentCaptor = ArgumentCaptor.forClass(Token.class); verify(this.tokenValidator).validate(tokenArgumentCaptor.capture()); @@ -141,7 +144,7 @@ public class CloudFoundrySecurityInterceptorTests { this.request.addHeader("Authorization", "Bearer " + accessToken); given(this.securityService.getAccessLevel(accessToken, "my-app-id")) .willReturn(AccessLevel.RESTRICTED); - CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor + SecurityResponse response = this.interceptor .preHandle(this.request, "info"); ArgumentCaptor tokenArgumentCaptor = ArgumentCaptor.forClass(Token.class); verify(this.tokenValidator).validate(tokenArgumentCaptor.capture()); diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityServiceTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityServiceTests.java similarity index 98% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityServiceTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityServiceTests.java index 5eb2396cdf..5304465197 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityServiceTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityServiceTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.util.Map; @@ -23,6 +23,8 @@ import org.junit.Rule; import org.junit.Test; import org.junit.rules.ExpectedException; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AccessLevel; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AuthorizationExceptionMatcher; import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer; import org.springframework.boot.web.client.RestTemplateBuilder; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactoryTests.java similarity index 99% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactoryTests.java index 2a00eed556..8e3a474149 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactoryTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import javax.net.ssl.SSLHandshakeException; diff --git a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidatorTests.java b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidatorTests.java similarity index 98% rename from spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidatorTests.java rename to spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidatorTests.java index 8891dbe2ab..a8f77238bb 100644 --- a/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidatorTests.java +++ b/spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidatorTests.java @@ -14,7 +14,7 @@ * limitations under the License. */ -package org.springframework.boot.actuate.autoconfigure.cloudfoundry; +package org.springframework.boot.actuate.autoconfigure.cloudfoundry.servlet; import java.io.ByteArrayOutputStream; import java.io.IOException; @@ -37,7 +37,9 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.MockitoAnnotations; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.AuthorizationExceptionMatcher; import org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.autoconfigure.cloudfoundry.Token; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.Base64Utils; import org.springframework.util.StreamUtils; diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java new file mode 100644 index 0000000000..11a39a335f --- /dev/null +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/AbstractWebFluxEndpointHandlerMapping.java @@ -0,0 +1,196 @@ +/* + * 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.endpoint.web.reactive; + +import java.lang.reflect.Method; +import java.util.Collection; +import java.util.Map; + +import reactor.core.publisher.Mono; +import reactor.core.publisher.MonoSink; +import reactor.core.scheduler.Schedulers; + +import org.springframework.boot.actuate.endpoint.EndpointInfo; +import org.springframework.boot.actuate.endpoint.OperationInvoker; +import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; +import org.springframework.boot.actuate.endpoint.web.OperationRequestPredicate; +import org.springframework.boot.actuate.endpoint.web.WebEndpointOperation; +import org.springframework.boot.endpoint.web.EndpointMapping; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.reactive.HandlerMapping; +import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; +import org.springframework.web.reactive.result.condition.PatternsRequestCondition; +import org.springframework.web.reactive.result.condition.ProducesRequestCondition; +import org.springframework.web.reactive.result.condition.RequestMethodsRequestCondition; +import org.springframework.web.reactive.result.method.RequestMappingInfo; +import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; +import org.springframework.web.util.pattern.PathPatternParser; + +/** + * A custom {@link HandlerMapping} that makes web endpoints available over HTTP using + * Spring WebFlux. + * + * @author Andy Wilkinson + * @author Madhura Bhave + */ +public abstract class AbstractWebFluxEndpointHandlerMapping extends RequestMappingInfoHandlerMapping { + + private static final PathPatternParser pathPatternParser = new PathPatternParser(); + + private final EndpointMapping endpointMapping; + + private final Collection> webEndpoints; + + private final EndpointMediaTypes endpointMediaTypes; + + private final CorsConfiguration corsConfiguration; + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointMapping the base mapping for all endpoints + * @param collection the web endpoints + * @param endpointMediaTypes media types consumed and produced by the endpoints + */ + public AbstractWebFluxEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection> collection, + EndpointMediaTypes endpointMediaTypes) { + this(endpointMapping, collection, endpointMediaTypes, null); + } + + /** + * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the + * operations of the given {@code webEndpoints}. + * @param endpointMapping the base mapping for all endpoints + * @param webEndpoints the web endpoints + * @param endpointMediaTypes media types consumed and produced by the endpoints + * @param corsConfiguration the CORS configuration for the endpoints + */ + public AbstractWebFluxEndpointHandlerMapping(EndpointMapping endpointMapping, + Collection> webEndpoints, + EndpointMediaTypes endpointMediaTypes, CorsConfiguration corsConfiguration) { + this.endpointMapping = endpointMapping; + this.webEndpoints = webEndpoints; + this.endpointMediaTypes = endpointMediaTypes; + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + @Override + protected void initHandlerMethods() { + this.webEndpoints.stream() + .flatMap((webEndpoint) -> webEndpoint.getOperations().stream()) + .forEach(this::registerMappingForOperation); + if (StringUtils.hasText(this.endpointMapping.getPath())) { + registerLinksMapping(); + } + } + + private void registerLinksMapping() { + registerMapping( + new RequestMappingInfo( + new PatternsRequestCondition( + pathPatternParser.parse(this.endpointMapping.getPath())), + new RequestMethodsRequestCondition(RequestMethod.GET), null, null, + null, + new ProducesRequestCondition( + this.endpointMediaTypes.getProduced() + .toArray(new String[this.endpointMediaTypes + .getProduced().size()])), + null), + this, getLinks()); + } + + protected RequestMappingInfo createRequestMappingInfo( + WebEndpointOperation operationInfo) { + OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate(); + PatternsRequestCondition patterns = new PatternsRequestCondition(pathPatternParser + .parse(this.endpointMapping.createSubPath(requestPredicate.getPath()))); + RequestMethodsRequestCondition methods = new RequestMethodsRequestCondition( + RequestMethod.valueOf(requestPredicate.getHttpMethod().name())); + ConsumesRequestCondition consumes = new ConsumesRequestCondition( + toStringArray(requestPredicate.getConsumes())); + ProducesRequestCondition produces = new ProducesRequestCondition( + toStringArray(requestPredicate.getProduces())); + return new RequestMappingInfo(null, patterns, methods, null, null, consumes, + produces, null); + } + + private String[] toStringArray(Collection collection) { + return collection.toArray(new String[collection.size()]); + } + + @Override + protected CorsConfiguration initCorsConfiguration(Object handler, Method method, + RequestMappingInfo mapping) { + return this.corsConfiguration; + } + + public Collection> getEndpoints() { + return this.webEndpoints; + } + + protected abstract Method getLinks(); + + protected abstract void registerMappingForOperation(WebEndpointOperation operation); + + @Override + protected boolean isHandler(Class beanType) { + return false; + } + + @Override + protected RequestMappingInfo getMappingForMethod(Method method, + Class handlerType) { + return null; + } + + /** + * An {@link OperationInvoker} that performs the invocation of a blocking operation on + * a separate thread using Reactor's {@link Schedulers#elastic() elastic scheduler}. + */ + protected static final class ElasticSchedulerOperationInvoker + implements OperationInvoker { + + private final OperationInvoker delegate; + + public ElasticSchedulerOperationInvoker(OperationInvoker delegate) { + this.delegate = delegate; + } + + @Override + public Object invoke(Map arguments) { + return Mono.create((sink) -> Schedulers.elastic() + .schedule(() -> invoke(arguments, sink))); + } + + private void invoke(Map arguments, MonoSink sink) { + try { + Object result = this.delegate.invoke(arguments); + sink.success(result); + } + catch (Exception ex) { + sink.error(ex); + } + } + + } + +} + diff --git a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointHandlerMapping.java b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointHandlerMapping.java index d044114b85..7de6ad66ab 100644 --- a/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointHandlerMapping.java +++ b/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/web/reactive/WebFluxEndpointHandlerMapping.java @@ -24,8 +24,6 @@ import java.util.Map; import org.reactivestreams.Publisher; import reactor.core.publisher.Mono; -import reactor.core.publisher.MonoSink; -import reactor.core.scheduler.Schedulers; import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.actuate.endpoint.EndpointInfo; @@ -36,7 +34,6 @@ import org.springframework.boot.actuate.endpoint.ParametersMissingException; import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver; import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes; import org.springframework.boot.actuate.endpoint.web.Link; -import org.springframework.boot.actuate.endpoint.web.OperationRequestPredicate; import org.springframework.boot.actuate.endpoint.web.WebEndpointOperation; import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse; import org.springframework.boot.endpoint.web.EndpointMapping; @@ -45,21 +42,12 @@ import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.http.server.reactive.ServerHttpRequest; import org.springframework.util.ReflectionUtils; -import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.reactive.HandlerMapping; -import org.springframework.web.reactive.result.condition.ConsumesRequestCondition; -import org.springframework.web.reactive.result.condition.PatternsRequestCondition; -import org.springframework.web.reactive.result.condition.ProducesRequestCondition; -import org.springframework.web.reactive.result.condition.RequestMethodsRequestCondition; -import org.springframework.web.reactive.result.method.RequestMappingInfo; -import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.util.UriComponentsBuilder; -import org.springframework.web.util.pattern.PathPatternParser; /** * A custom {@link HandlerMapping} that makes web endpoints available over HTTP using @@ -68,10 +56,7 @@ import org.springframework.web.util.pattern.PathPatternParser; * @author Andy Wilkinson * @since 2.0.0 */ -public class WebFluxEndpointHandlerMapping extends RequestMappingInfoHandlerMapping - implements InitializingBean { - - private static final PathPatternParser pathPatternParser = new PathPatternParser(); +public class WebFluxEndpointHandlerMapping extends AbstractWebFluxEndpointHandlerMapping implements InitializingBean { private final Method handleRead = ReflectionUtils .findMethod(ReadOperationHandler.class, "handle", ServerWebExchange.class); @@ -84,14 +69,6 @@ public class WebFluxEndpointHandlerMapping extends RequestMappingInfoHandlerMapp private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver(); - private final EndpointMapping endpointMapping; - - private final Collection> webEndpoints; - - private final EndpointMediaTypes endpointMediaTypes; - - private final CorsConfiguration corsConfiguration; - /** * Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the * operations of the given {@code webEndpoints}. @@ -116,45 +93,17 @@ public class WebFluxEndpointHandlerMapping extends RequestMappingInfoHandlerMapp public WebFluxEndpointHandlerMapping(EndpointMapping endpointMapping, Collection> webEndpoints, EndpointMediaTypes endpointMediaTypes, CorsConfiguration corsConfiguration) { - this.endpointMapping = endpointMapping; - this.webEndpoints = webEndpoints; - this.endpointMediaTypes = endpointMediaTypes; - this.corsConfiguration = corsConfiguration; + super(endpointMapping, webEndpoints, endpointMediaTypes, corsConfiguration); setOrder(-100); } @Override - protected void initHandlerMethods() { - this.webEndpoints.stream() - .flatMap((webEndpoint) -> webEndpoint.getOperations().stream()) - .forEach(this::registerMappingForOperation); - if (StringUtils.hasText(this.endpointMapping.getPath())) { - registerLinksMapping(); - } - } - - private void registerLinksMapping() { - registerMapping( - new RequestMappingInfo( - new PatternsRequestCondition( - pathPatternParser.parse(this.endpointMapping.getPath())), - new RequestMethodsRequestCondition(RequestMethod.GET), null, null, - null, - new ProducesRequestCondition( - this.endpointMediaTypes.getProduced() - .toArray(new String[this.endpointMediaTypes - .getProduced().size()])), - null), - this, this.links); + protected Method getLinks() { + return this.links; } @Override - protected CorsConfiguration initCorsConfiguration(Object handler, Method method, - RequestMappingInfo mapping) { - return this.corsConfiguration; - } - - private void registerMappingForOperation(WebEndpointOperation operation) { + protected void registerMappingForOperation(WebEndpointOperation operation) { OperationType operationType = operation.getType(); OperationInvoker operationInvoker = operation.getInvoker(); if (operation.isBlocking()) { @@ -162,50 +111,19 @@ public class WebFluxEndpointHandlerMapping extends RequestMappingInfoHandlerMapp } registerMapping(createRequestMappingInfo(operation), operationType == OperationType.WRITE - ? new WriteOperationHandler(operationInvoker) - : new ReadOperationHandler(operationInvoker), + ? new WebFluxEndpointHandlerMapping.WriteOperationHandler(operationInvoker) + : new WebFluxEndpointHandlerMapping.ReadOperationHandler(operationInvoker), operationType == OperationType.WRITE ? this.handleWrite : this.handleRead); } - private RequestMappingInfo createRequestMappingInfo( - WebEndpointOperation operationInfo) { - OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate(); - PatternsRequestCondition patterns = new PatternsRequestCondition(pathPatternParser - .parse(this.endpointMapping.createSubPath(requestPredicate.getPath()))); - RequestMethodsRequestCondition methods = new RequestMethodsRequestCondition( - RequestMethod.valueOf(requestPredicate.getHttpMethod().name())); - ConsumesRequestCondition consumes = new ConsumesRequestCondition( - toStringArray(requestPredicate.getConsumes())); - ProducesRequestCondition produces = new ProducesRequestCondition( - toStringArray(requestPredicate.getProduces())); - return new RequestMappingInfo(null, patterns, methods, null, null, consumes, - produces, null); - } - - private String[] toStringArray(Collection collection) { - return collection.toArray(new String[collection.size()]); - } - - @Override - protected boolean isHandler(Class beanType) { - return false; - } - - @Override - protected RequestMappingInfo getMappingForMethod(Method method, - Class handlerType) { - return null; - } - @ResponseBody private Map> links(ServerHttpRequest request) { return Collections.singletonMap("_links", - this.endpointLinksResolver.resolveLinks(this.webEndpoints, + this.endpointLinksResolver.resolveLinks(getEndpoints(), UriComponentsBuilder.fromUri(request.getURI()).replaceQuery(null) .toUriString())); } - /** * Base class for handlers for endpoint operations. */ @@ -286,35 +204,4 @@ public class WebFluxEndpointHandlerMapping extends RequestMappingInfoHandlerMapp } - /** - * An {@link OperationInvoker} that performs the invocation of a blocking operation on - * a separate thread using Reactor's {@link Schedulers#elastic() elastic scheduler}. - */ - private static final class ElasticSchedulerOperationInvoker - implements OperationInvoker { - - private final OperationInvoker delegate; - - private ElasticSchedulerOperationInvoker(OperationInvoker delegate) { - this.delegate = delegate; - } - - @Override - public Object invoke(Map arguments) { - return Mono.create((sink) -> Schedulers.elastic() - .schedule(() -> invoke(arguments, sink))); - } - - private void invoke(Map arguments, MonoSink sink) { - try { - Object result = this.delegate.invoke(arguments); - sink.success(result); - } - catch (Exception ex) { - sink.error(ex); - } - } - - } - } diff --git a/spring-boot-project/spring-boot-parent/pom.xml b/spring-boot-project/spring-boot-parent/pom.xml index 403a0a27ab..39fd21360f 100644 --- a/spring-boot-project/spring-boot-parent/pom.xml +++ b/spring-boot-project/spring-boot-parent/pom.xml @@ -61,7 +61,12 @@ com.squareup.okhttp3 okhttp - 3.4.1 + 3.9.0 + + + com.squareup.okhttp3 + mockwebserver + 3.9.0 com.vaadin.external.google