32 changed files with 2474 additions and 273 deletions
@ -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); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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<ResponseEntity<Object>> 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<String, Link> links = this.endpointLinksResolver.resolveLinks(getEndpoints(), |
||||||
|
request.getURI().toString()); |
||||||
|
return new ResponseEntity<>(Collections.singletonMap("_links", |
||||||
|
getAccessibleLinks(accessLevel, links)), HttpStatus.OK); |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
private Map<String, Link> getAccessibleLinks(AccessLevel accessLevel, Map<String, Link> 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<EndpointInfo<WebEndpointOperation>> 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<ResponseEntity<Object>> doHandle(ServerWebExchange exchange, |
||||||
|
Map<String, String> body) { |
||||||
|
return this.securityInterceptor |
||||||
|
.preHandle(exchange, this.endpointId) |
||||||
|
.flatMap(securityResponse -> { |
||||||
|
if (!securityResponse.getStatus().equals(HttpStatus.OK)) { |
||||||
|
return Mono.just(new ResponseEntity<>(securityResponse.getStatus())); |
||||||
|
} |
||||||
|
Map<String, Object> 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<ResponseEntity<Object>> 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<Object> 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<ResponseEntity<Object>> handle(ServerWebExchange exchange, |
||||||
|
@RequestBody(required = false) Map<String, String> 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<ResponseEntity<Object>> handle(ServerWebExchange exchange) { |
||||||
|
return doHandle(exchange, null); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
@ -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<SecurityResponse> SUCCESS = Mono.just(SecurityResponse.success()); |
||||||
|
|
||||||
|
ReactiveCloudFoundrySecurityInterceptor(ReactiveTokenValidator tokenValidator, |
||||||
|
ReactiveCloudFoundrySecurityService cloudFoundrySecurityService, |
||||||
|
String applicationId) { |
||||||
|
this.tokenValidator = tokenValidator; |
||||||
|
this.cloudFoundrySecurityService = cloudFoundrySecurityService; |
||||||
|
this.applicationId = applicationId; |
||||||
|
} |
||||||
|
|
||||||
|
Mono<SecurityResponse> 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<Void> 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<SecurityResponse> 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())); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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<String> 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<AccessLevel> 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<Map<String, String>> fetchTokenKeys() { |
||||||
|
return getUaaUrl() |
||||||
|
.flatMap(url -> this.webClient.get() |
||||||
|
.uri(url + "/token_keys") |
||||||
|
.retrieve().bodyToMono(new ParameterizedTypeReference<Map<String, Object>>() { }) |
||||||
|
.map(this::extractTokenKeys) |
||||||
|
.onErrorMap((throwable -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, |
||||||
|
throwable.getMessage())))); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
private Map<String, String> extractTokenKeys(Map<String, Object> response) { |
||||||
|
Map<String, String> 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<String> 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; |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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<Void> validate(Token token) { |
||||||
|
return validateAlgorithm(token) |
||||||
|
.then(validateKeyIdAndSignature(token)) |
||||||
|
.then(validateExpiry(token)) |
||||||
|
.then(validateIssuer(token)) |
||||||
|
.then(validateAudience(token)); |
||||||
|
} |
||||||
|
|
||||||
|
private Mono<Void> 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<Void> 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<String, String> 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<Void> 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<Void> 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<Void> 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(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
108
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java
108
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfiguration.java
49
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptor.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java
49
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptor.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptor.java
4
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityService.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityService.java
4
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityService.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityService.java
14
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java
14
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryWebEndpointServletHandlerMapping.java
2
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactory.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactory.java
2
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactory.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactory.java
8
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidator.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidator.java
8
spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidator.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/main/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidator.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<WebTestClient> clientConsumer) { |
||||||
|
BiConsumer<ApplicationContext, WebTestClient> 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<ReactiveWebServerInitializedEvent> 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<String, Object> readAll() { |
||||||
|
return Collections.singletonMap("All", true); |
||||||
|
} |
||||||
|
|
||||||
|
@ReadOperation |
||||||
|
public Map<String, Object> 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<String, Object> readAll() { |
||||||
|
return Collections.singletonMap("All", true); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
@Endpoint(id = "info") |
||||||
|
static class TestInfoEndpoint { |
||||||
|
|
||||||
|
@ReadOperation |
||||||
|
public Map<String, Object> 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); |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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<SecurityWebFilterChain> filters = (List<SecurityWebFilterChain>) 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<EndpointInfo<WebEndpointOperation>> endpoints = (List<EndpointInfo<WebEndpointOperation>>) 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<EndpointInfo<WebEndpointOperation>> endpoints = (List<EndpointInfo<WebEndpointOperation>>) 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); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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()); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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<MockResponse> consumer) { |
||||||
|
MockResponse response = new MockResponse(); |
||||||
|
consumer.accept(response); |
||||||
|
this.server.enqueue(response); |
||||||
|
} |
||||||
|
|
||||||
|
private void expectRequest(Consumer<RecordedRequest> consumer) throws InterruptedException { |
||||||
|
consumer.accept(this.server.takeRequest()); |
||||||
|
} |
||||||
|
|
||||||
|
private void expectRequestCount(int count) { |
||||||
|
assertThat(count).isEqualTo(this.server.getRequestCount()); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
@ -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<String, String> INVALID_KEYS = Collections |
||||||
|
.singletonMap("invalid-key", INVALID_KEY); |
||||||
|
|
||||||
|
private static final Map<String, String> 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<String, String> 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(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
2
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java
2
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryActuatorAutoConfigurationTests.java
7
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java
7
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundryMvcWebEndpointIntegrationTests.java
21
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptorTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptorTests.java
21
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityInterceptorTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityInterceptorTests.java
4
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityServiceTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityServiceTests.java
4
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/CloudFoundrySecurityServiceTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/CloudFoundrySecurityServiceTests.java
2
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactoryTests.java
2
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/SkipSslVerificationHttpRequestFactoryTests.java
4
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidatorTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidatorTests.java
4
spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/TokenValidatorTests.java → spring-boot-project/spring-boot-actuator-autoconfigure/src/test/java/org/springframework/boot/actuate/autoconfigure/cloudfoundry/servlet/TokenValidatorTests.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<EndpointInfo<WebEndpointOperation>> 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<EndpointInfo<WebEndpointOperation>> 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<EndpointInfo<WebEndpointOperation>> 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<String> collection) { |
||||||
|
return collection.toArray(new String[collection.size()]); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
protected CorsConfiguration initCorsConfiguration(Object handler, Method method, |
||||||
|
RequestMappingInfo mapping) { |
||||||
|
return this.corsConfiguration; |
||||||
|
} |
||||||
|
|
||||||
|
public Collection<EndpointInfo<WebEndpointOperation>> 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<String, Object> arguments) { |
||||||
|
return Mono.create((sink) -> Schedulers.elastic() |
||||||
|
.schedule(() -> invoke(arguments, sink))); |
||||||
|
} |
||||||
|
|
||||||
|
private void invoke(Map<String, Object> arguments, MonoSink<Object> sink) { |
||||||
|
try { |
||||||
|
Object result = this.delegate.invoke(arguments); |
||||||
|
sink.success(result); |
||||||
|
} |
||||||
|
catch (Exception ex) { |
||||||
|
sink.error(ex); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
Loading…
Reference in new issue