parent
7c5d2fadd3
commit
d50fe8874f
@ -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