Reinstate CloudFoundry support for actuators
Closes gh-9996
This commit is contained in:
parent
bacbe0459b
commit
4990b52b72
|
|
@ -0,0 +1,67 @@
|
|||
/*
|
||||
* Copyright 2012-2016 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.cloudfoundry;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
/**
|
||||
* The specific access level granted to the cloud foundry user that's calling the
|
||||
* endpoints.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
enum AccessLevel {
|
||||
|
||||
/**
|
||||
* Restricted access to a limited set of endpoints.
|
||||
*/
|
||||
RESTRICTED("", "health", "info"),
|
||||
|
||||
/**
|
||||
* Full access to all endpoints.
|
||||
*/
|
||||
FULL;
|
||||
|
||||
private static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel";
|
||||
|
||||
private final List<String> endpointPaths;
|
||||
|
||||
AccessLevel(String... endpointPaths) {
|
||||
this.endpointPaths = Arrays.asList(endpointPaths);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the access level should allow access to the specified endpoint path.
|
||||
* @param endpointPath the endpoint path
|
||||
* @return {@code true} if access is allowed
|
||||
*/
|
||||
public boolean isAccessAllowed(String endpointPath) {
|
||||
return this.endpointPaths.isEmpty() || this.endpointPaths.contains(endpointPath);
|
||||
}
|
||||
|
||||
public void put(HttpServletRequest request) {
|
||||
request.setAttribute(REQUEST_ATTRIBUTE, this);
|
||||
}
|
||||
|
||||
public static AccessLevel get(HttpServletRequest request) {
|
||||
return (AccessLevel) request.getAttribute(REQUEST_ATTRIBUTE);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,132 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.infrastructure.EndpointProvider;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.infrastructure.ServletEndpointAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigureAfter;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.autoconfigure.security.SecurityProperties;
|
||||
import org.springframework.boot.cloud.CloudPlatform;
|
||||
import org.springframework.boot.endpoint.web.WebEndpointOperation;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.WebSecurityConfigurer;
|
||||
import org.springframework.security.config.annotation.web.builders.WebSecurity;
|
||||
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.DispatcherServlet;
|
||||
|
||||
/**
|
||||
* {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for
|
||||
* cloud foundry to use.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "management.cloudfoundry", name = "enabled", matchIfMissing = true)
|
||||
@AutoConfigureAfter(ServletEndpointAutoConfiguration.class)
|
||||
@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY)
|
||||
public class CloudFoundryActuatorAutoConfiguration {
|
||||
|
||||
/**
|
||||
* Configuration for MVC endpoints on Cloud Foundry.
|
||||
*/
|
||||
@Configuration
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
@ConditionalOnClass(DispatcherServlet.class)
|
||||
@ConditionalOnBean(DispatcherServlet.class)
|
||||
static class MvcWebEndpointConfiguration {
|
||||
|
||||
@Bean
|
||||
public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
|
||||
EndpointProvider<WebEndpointOperation> provider,
|
||||
Environment environment, RestTemplateBuilder builder) {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = new CloudFoundryWebEndpointServletHandlerMapping(
|
||||
"/cloudfoundryapplication", provider.getEndpoints(),
|
||||
getCorsConfiguration(), getSecurityInterceptor(builder, environment));
|
||||
return handlerMapping;
|
||||
}
|
||||
|
||||
private CloudFoundrySecurityInterceptor getSecurityInterceptor(
|
||||
RestTemplateBuilder restTemplateBuilder, Environment environment) {
|
||||
CloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService(
|
||||
restTemplateBuilder, environment);
|
||||
TokenValidator tokenValidator = new TokenValidator(cloudfoundrySecurityService);
|
||||
return new CloudFoundrySecurityInterceptor(
|
||||
tokenValidator, cloudfoundrySecurityService,
|
||||
environment.getProperty("vcap.application.application_id"));
|
||||
}
|
||||
|
||||
private CloudFoundrySecurityService getCloudFoundrySecurityService(
|
||||
RestTemplateBuilder restTemplateBuilder, Environment environment) {
|
||||
String cloudControllerUrl = environment.getProperty("vcap.application.cf_api");
|
||||
boolean skipSslValidation = environment.getProperty(
|
||||
"management.cloudfoundry.skip-ssl-validation", Boolean.class, false);
|
||||
return cloudControllerUrl == null ? null
|
||||
: new CloudFoundrySecurityService(restTemplateBuilder, cloudControllerUrl,
|
||||
skipSslValidation);
|
||||
}
|
||||
|
||||
private CorsConfiguration getCorsConfiguration() {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL);
|
||||
corsConfiguration.setAllowedMethods(
|
||||
Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
|
||||
corsConfiguration.setAllowedHeaders(
|
||||
Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type"));
|
||||
return corsConfiguration;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link WebSecurityConfigurer} to tell Spring Security to
|
||||
* ignore cloudfoundry specific paths. The Cloud foundry endpoints
|
||||
* are protected by their own security interceptor.
|
||||
*/
|
||||
@ConditionalOnClass(WebSecurity.class)
|
||||
@Order(SecurityProperties.IGNORED_ORDER)
|
||||
@Configuration
|
||||
public static class IgnoredPathsWebSecurityConfigurer
|
||||
implements WebSecurityConfigurer<WebSecurity> {
|
||||
|
||||
@Override
|
||||
public void init(WebSecurity builder) throws Exception {
|
||||
builder.ignoring().requestMatchers(new AntPathRequestMatcher("/cloudfoundryapplication/**"));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void configure(WebSecurity builder) throws Exception {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Authorization exceptions thrown to limit access to the endpoints.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryAuthorizationException extends RuntimeException {
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
CloudFoundryAuthorizationException(Reason reason, String message) {
|
||||
this(reason, message, null);
|
||||
}
|
||||
|
||||
CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) {
|
||||
super(message);
|
||||
this.reason = reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the status code that should be returned to the client.
|
||||
* @return the HTTP status code
|
||||
*/
|
||||
public HttpStatus getStatusCode() {
|
||||
return getReason().getStatus();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the reason why the authorization exception was thrown.
|
||||
* @return the reason
|
||||
*/
|
||||
public Reason getReason() {
|
||||
return this.reason;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reasons why the exception can be thrown.
|
||||
*/
|
||||
enum Reason {
|
||||
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN),
|
||||
|
||||
INVALID_AUDIENCE(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
INVALID_ISSUER(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
INVALID_KEY_ID(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
INVALID_TOKEN(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
MISSING_AUTHORIZATION(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
UNSUPPORTED_TOKEN_SIGNING_ALGORITHM(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
Reason(HttpStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.cloudfoundry;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.cors.CorsUtils;
|
||||
|
||||
/**
|
||||
* Security interceptor to validate the cloud foundry token.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundrySecurityInterceptor {
|
||||
|
||||
private static final Log logger = LogFactory
|
||||
.getLog(CloudFoundrySecurityInterceptor.class);
|
||||
|
||||
private final TokenValidator tokenValidator;
|
||||
|
||||
private final CloudFoundrySecurityService cloudFoundrySecurityService;
|
||||
|
||||
private final String applicationId;
|
||||
|
||||
private static SecurityResponse SUCCESS = SecurityResponse.success();
|
||||
|
||||
CloudFoundrySecurityInterceptor(TokenValidator tokenValidator,
|
||||
CloudFoundrySecurityService cloudFoundrySecurityService,
|
||||
String applicationId) {
|
||||
this.tokenValidator = tokenValidator;
|
||||
this.cloudFoundrySecurityService = cloudFoundrySecurityService;
|
||||
this.applicationId = applicationId;
|
||||
}
|
||||
|
||||
SecurityResponse preHandle(HttpServletRequest request,
|
||||
String endpointId) {
|
||||
if (CorsUtils.isPreFlightRequest(request)) {
|
||||
return SecurityResponse.success();
|
||||
}
|
||||
try {
|
||||
if (!StringUtils.hasText(this.applicationId)) {
|
||||
throw new CloudFoundryAuthorizationException(CloudFoundryAuthorizationException.Reason.SERVICE_UNAVAILABLE,
|
||||
"Application id is not available");
|
||||
}
|
||||
if (this.cloudFoundrySecurityService == null) {
|
||||
throw new CloudFoundryAuthorizationException(CloudFoundryAuthorizationException.Reason.SERVICE_UNAVAILABLE,
|
||||
"Cloud controller URL is not available");
|
||||
}
|
||||
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
|
||||
return SUCCESS;
|
||||
}
|
||||
check(request, endpointId);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.error(ex);
|
||||
if (ex instanceof CloudFoundryAuthorizationException) {
|
||||
CloudFoundryAuthorizationException cfException = (CloudFoundryAuthorizationException) ex;
|
||||
return new SecurityResponse(cfException.getStatusCode(), "{\"security_error\":\"" + cfException.getMessage() + "\"}");
|
||||
}
|
||||
return new SecurityResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
}
|
||||
return SecurityResponse.success();
|
||||
}
|
||||
|
||||
private void check(HttpServletRequest request, String path)
|
||||
throws Exception {
|
||||
Token token = getToken(request);
|
||||
this.tokenValidator.validate(token);
|
||||
AccessLevel accessLevel = this.cloudFoundrySecurityService
|
||||
.getAccessLevel(token.toString(), this.applicationId);
|
||||
if (!accessLevel.isAccessAllowed(path)) {
|
||||
throw new CloudFoundryAuthorizationException(CloudFoundryAuthorizationException.Reason.ACCESS_DENIED,
|
||||
"Access denied");
|
||||
}
|
||||
accessLevel.put(request);
|
||||
}
|
||||
|
||||
private Token getToken(HttpServletRequest request) {
|
||||
String authorization = request.getHeader("Authorization");
|
||||
String bearerPrefix = "bearer ";
|
||||
if (authorization == null
|
||||
|| !authorization.toLowerCase().startsWith(bearerPrefix)) {
|
||||
throw new CloudFoundryAuthorizationException(CloudFoundryAuthorizationException.Reason.MISSING_AUTHORIZATION,
|
||||
"Authorization header is missing or invalid");
|
||||
}
|
||||
return new Token(authorization.substring(bearerPrefix.length()));
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the security interceptor.
|
||||
*/
|
||||
static class SecurityResponse {
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
private final String message;
|
||||
|
||||
SecurityResponse(HttpStatus status) {
|
||||
this (status, null);
|
||||
}
|
||||
|
||||
SecurityResponse(HttpStatus status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
static SecurityResponse success() {
|
||||
return new SecurityResponse(HttpStatus.OK);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,146 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Cloud Foundry security service to handle REST calls to the cloud controller and UAA.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundrySecurityService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
private final String cloudControllerUrl;
|
||||
|
||||
private String uaaUrl;
|
||||
|
||||
CloudFoundrySecurityService(RestTemplateBuilder restTemplateBuilder,
|
||||
String cloudControllerUrl, boolean skipSslValidation) {
|
||||
Assert.notNull(restTemplateBuilder, "RestTemplateBuilder must not be null");
|
||||
Assert.notNull(cloudControllerUrl, "CloudControllerUrl must not be null");
|
||||
if (skipSslValidation) {
|
||||
restTemplateBuilder = restTemplateBuilder
|
||||
.requestFactory(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
this.restTemplate = restTemplateBuilder.build();
|
||||
this.cloudControllerUrl = cloudControllerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the access level that should be granted to the given token.
|
||||
* @param token the token
|
||||
* @param applicationId the cloud foundry application ID
|
||||
* @return the access level that should be granted
|
||||
* @throws CloudFoundryAuthorizationException if the token is not authorized
|
||||
*/
|
||||
public AccessLevel getAccessLevel(String token, String applicationId)
|
||||
throws CloudFoundryAuthorizationException {
|
||||
try {
|
||||
URI uri = getPermissionsUri(applicationId);
|
||||
RequestEntity<?> request = RequestEntity.get(uri)
|
||||
.header("Authorization", "bearer " + token).build();
|
||||
Map<?, ?> body = this.restTemplate.exchange(request, Map.class).getBody();
|
||||
if (Boolean.TRUE.equals(body.get("read_sensitive_data"))) {
|
||||
return AccessLevel.FULL;
|
||||
}
|
||||
return AccessLevel.RESTRICTED;
|
||||
}
|
||||
catch (HttpClientErrorException ex) {
|
||||
if (ex.getStatusCode().equals(HttpStatus.FORBIDDEN)) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED,
|
||||
"Access denied");
|
||||
}
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
|
||||
"Invalid token", ex);
|
||||
}
|
||||
catch (HttpServerErrorException ex) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"Cloud controller not reachable");
|
||||
}
|
||||
}
|
||||
|
||||
private URI getPermissionsUri(String applicationId) {
|
||||
try {
|
||||
return new URI(this.cloudControllerUrl + "/v2/apps/" + applicationId
|
||||
+ "/permissions");
|
||||
}
|
||||
catch (URISyntaxException ex) {
|
||||
throw new IllegalStateException(ex);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return all token keys known by the UAA.
|
||||
* @return a list of token keys
|
||||
*/
|
||||
public Map<String, String> fetchTokenKeys() {
|
||||
try {
|
||||
return extractTokenKeys(this.restTemplate
|
||||
.getForObject(getUaaUrl() + "/token_keys", Map.class));
|
||||
}
|
||||
catch (HttpStatusCodeException e) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"UAA not reachable");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> extractTokenKeys(Map<?, ?> response) {
|
||||
Map<String, String> tokenKeys = new HashMap<String, String>();
|
||||
for (Object key : (List<?>) response.get("keys")) {
|
||||
Map<?, ?> tokenKey = (Map<?, ?>) key;
|
||||
tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value"));
|
||||
}
|
||||
return tokenKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the URL of the UAA.
|
||||
* @return the UAA url
|
||||
*/
|
||||
public String getUaaUrl() {
|
||||
if (this.uaaUrl == null) {
|
||||
try {
|
||||
Map<?, ?> response = this.restTemplate
|
||||
.getForObject(this.cloudControllerUrl + "/info", Map.class);
|
||||
this.uaaUrl = (String) response.get("token_endpoint");
|
||||
}
|
||||
catch (HttpStatusCodeException ex) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"Unable to fetch token keys from UAA");
|
||||
}
|
||||
}
|
||||
return this.uaaUrl;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,182 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Arrays;
|
||||
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 javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.boot.endpoint.EndpointInfo;
|
||||
import org.springframework.boot.endpoint.OperationInvoker;
|
||||
import org.springframework.boot.endpoint.ParameterMappingException;
|
||||
import org.springframework.boot.endpoint.web.EndpointLinksResolver;
|
||||
import org.springframework.boot.endpoint.web.Link;
|
||||
import org.springframework.boot.endpoint.web.WebEndpointOperation;
|
||||
import org.springframework.boot.endpoint.web.WebEndpointResponse;
|
||||
import org.springframework.boot.endpoint.web.mvc.AbstractWebEndpointServletHandlerMapping;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
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.servlet.HandlerMapping;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
|
||||
|
||||
/**
|
||||
* A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available
|
||||
* on Cloudfoundry specific URLS over HTTP using Spring MVC.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryWebEndpointServletHandlerMapping extends AbstractWebEndpointServletHandlerMapping {
|
||||
|
||||
private final Method handle = ReflectionUtils.findMethod(OperationHandler.class,
|
||||
"handle", HttpServletRequest.class, Map.class);
|
||||
|
||||
private final Method links = ReflectionUtils.findMethod(
|
||||
CloudFoundryWebEndpointServletHandlerMapping.class, "links", HttpServletRequest.class, HttpServletResponse.class);
|
||||
|
||||
private static final Log logger = LogFactory
|
||||
.getLog(CloudFoundryWebEndpointServletHandlerMapping.class);
|
||||
|
||||
private final CloudFoundrySecurityInterceptor securityInterceptor;
|
||||
|
||||
private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver();
|
||||
|
||||
CloudFoundryWebEndpointServletHandlerMapping(String endpointPath, Collection<EndpointInfo<WebEndpointOperation>> webEndpoints, CorsConfiguration corsConfiguration, CloudFoundrySecurityInterceptor securityInterceptor) {
|
||||
super(endpointPath, webEndpoints, corsConfiguration);
|
||||
this.securityInterceptor = securityInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected Method getLinks() {
|
||||
return this.links;
|
||||
}
|
||||
|
||||
@ResponseBody
|
||||
private Map<String, Map<String, Link>> links(HttpServletRequest request, HttpServletResponse response) {
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, "");
|
||||
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
|
||||
sendFailureResponse(response, securityResponse);
|
||||
}
|
||||
AccessLevel accessLevel = AccessLevel.get(request);
|
||||
Map<String, Link> links = this.endpointLinksResolver
|
||||
.resolveLinks(getEndpoints(), request.getRequestURL().toString());
|
||||
Map<String, Link> filteredLinks = new LinkedHashMap<>();
|
||||
if (accessLevel == null) {
|
||||
return Collections.singletonMap("_links", filteredLinks);
|
||||
}
|
||||
filteredLinks = links.entrySet().stream()
|
||||
.filter(e -> e.getKey().equals("self") || accessLevel.isAccessAllowed(e.getKey()))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
return Collections.singletonMap("_links", filteredLinks);
|
||||
}
|
||||
|
||||
private void sendFailureResponse(HttpServletResponse response, CloudFoundrySecurityInterceptor.SecurityResponse securityResponse) {
|
||||
try {
|
||||
response.sendError(securityResponse.getStatus().value(), securityResponse.getMessage());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.debug("Failed to send error response", ex);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void registerMappingForOperation(WebEndpointOperation operation) {
|
||||
registerMapping(createRequestMappingInfo(operation),
|
||||
new OperationHandler(operation.getOperationInvoker(), operation.getId(), this.securityInterceptor), this.handle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handler which has the handler method and security interceptor.
|
||||
*/
|
||||
final class OperationHandler {
|
||||
|
||||
private final OperationInvoker operationInvoker;
|
||||
|
||||
private final String endpointId;
|
||||
|
||||
private final CloudFoundrySecurityInterceptor securityInterceptor;
|
||||
|
||||
OperationHandler(OperationInvoker operationInvoker, String id, CloudFoundrySecurityInterceptor securityInterceptor) {
|
||||
this.operationInvoker = operationInvoker;
|
||||
this.endpointId = id;
|
||||
this.securityInterceptor = securityInterceptor;
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
@ResponseBody
|
||||
public Object handle(HttpServletRequest request,
|
||||
@RequestBody(required = false) Map<String, String> body) {
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId);
|
||||
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
|
||||
return failureResponse(securityResponse);
|
||||
}
|
||||
Map<String, Object> arguments = new HashMap<>((Map<String, String>) request
|
||||
.getAttribute(HandlerMapping.URI_TEMPLATE_VARIABLES_ATTRIBUTE));
|
||||
HttpMethod httpMethod = HttpMethod.valueOf(request.getMethod());
|
||||
if (body != null && HttpMethod.POST == httpMethod) {
|
||||
arguments.putAll(body);
|
||||
}
|
||||
request.getParameterMap().forEach((name, values) -> arguments.put(name,
|
||||
values.length == 1 ? values[0] : Arrays.asList(values)));
|
||||
try {
|
||||
return handleResult(this.operationInvoker.invoke(arguments), httpMethod);
|
||||
}
|
||||
catch (ParameterMappingException ex) {
|
||||
return new ResponseEntity<Void>(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private Object failureResponse(CloudFoundrySecurityInterceptor.SecurityResponse response) {
|
||||
return handleResult(new WebEndpointResponse<>(response.getMessage(), response.getStatus().value()));
|
||||
}
|
||||
|
||||
private Object handleResult(Object result) {
|
||||
return handleResult(result, null);
|
||||
}
|
||||
|
||||
private Object handleResult(Object result, HttpMethod httpMethod) {
|
||||
if (result == null) {
|
||||
return new ResponseEntity<>(httpMethod == HttpMethod.GET
|
||||
? HttpStatus.NOT_FOUND : HttpStatus.NO_CONTENT);
|
||||
}
|
||||
if (!(result instanceof WebEndpointResponse)) {
|
||||
return result;
|
||||
}
|
||||
WebEndpointResponse<?> response = (WebEndpointResponse<?>) result;
|
||||
return new ResponseEntity<Object>(response.getBody(),
|
||||
HttpStatus.valueOf(response.getStatus()));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* Copyright 2012-2016 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.cloudfoundry;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.security.SecureRandom;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.SSLSession;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
||||
|
||||
/**
|
||||
* {@link SimpleClientHttpRequestFactory} that skips SSL certificate verification.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class SkipSslVerificationHttpRequestFactory extends SimpleClientHttpRequestFactory {
|
||||
|
||||
@Override
|
||||
protected void prepareConnection(HttpURLConnection connection, String httpMethod)
|
||||
throws IOException {
|
||||
if (connection instanceof HttpsURLConnection) {
|
||||
prepareHttpsConnection((HttpsURLConnection) connection);
|
||||
}
|
||||
super.prepareConnection(connection, httpMethod);
|
||||
}
|
||||
|
||||
private void prepareHttpsConnection(HttpsURLConnection connection) {
|
||||
connection.setHostnameVerifier(new SkipHostnameVerifier());
|
||||
try {
|
||||
connection.setSSLSocketFactory(createSslSocketFactory());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
|
||||
private SSLSocketFactory createSslSocketFactory() throws Exception {
|
||||
SSLContext context = SSLContext.getInstance("TLS");
|
||||
context.init(null, new TrustManager[] { new SkipX509TrustManager() },
|
||||
new SecureRandom());
|
||||
return context.getSocketFactory();
|
||||
}
|
||||
|
||||
private class SkipHostnameVerifier implements HostnameVerifier {
|
||||
|
||||
@Override
|
||||
public boolean verify(String s, SSLSession sslSession) {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static class SkipX509TrustManager implements X509TrustManager {
|
||||
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import java.nio.charset.Charset;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.json.JsonParserFactory;
|
||||
import org.springframework.util.Base64Utils;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* The JSON web token provided with each request that originates from Cloud Foundry.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class Token {
|
||||
|
||||
private static final Charset UTF_8 = Charset.forName("UTF-8");
|
||||
|
||||
private final String encoded;
|
||||
|
||||
private final String signature;
|
||||
|
||||
private final Map<String, Object> header;
|
||||
|
||||
private final Map<String, Object> claims;
|
||||
|
||||
Token(String encoded) {
|
||||
this.encoded = encoded;
|
||||
int firstPeriod = encoded.indexOf('.');
|
||||
int lastPeriod = encoded.lastIndexOf('.');
|
||||
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) {
|
||||
throw new CloudFoundryAuthorizationException(
|
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
|
||||
"JWT must have header, body and signature");
|
||||
}
|
||||
this.header = parseJson(encoded.substring(0, firstPeriod));
|
||||
this.claims = parseJson(encoded.substring(firstPeriod + 1, lastPeriod));
|
||||
this.signature = encoded.substring(lastPeriod + 1);
|
||||
if (!StringUtils.hasLength(this.signature)) {
|
||||
throw new CloudFoundryAuthorizationException(
|
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
|
||||
"Token must have non-empty crypto segment");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseJson(String base64) {
|
||||
try {
|
||||
byte[] bytes = Base64Utils.decodeFromUrlSafeString(base64);
|
||||
return JsonParserFactory.getJsonParser().parseMap(new String(bytes, UTF_8));
|
||||
}
|
||||
catch (RuntimeException ex) {
|
||||
throw new CloudFoundryAuthorizationException(
|
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
|
||||
"Token could not be parsed", ex);
|
||||
}
|
||||
}
|
||||
|
||||
public byte[] getContent() {
|
||||
return this.encoded.substring(0, this.encoded.lastIndexOf(".")).getBytes();
|
||||
}
|
||||
|
||||
public byte[] getSignature() {
|
||||
return Base64Utils.decodeFromUrlSafeString(this.signature);
|
||||
}
|
||||
|
||||
public String getSignatureAlgorithm() {
|
||||
return getRequired(this.header, "alg", String.class);
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return getRequired(this.claims, "iss", String.class);
|
||||
}
|
||||
|
||||
public long getExpiry() {
|
||||
return getRequired(this.claims, "exp", Integer.class).longValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<String> getScope() {
|
||||
return getRequired(this.claims, "scope", List.class);
|
||||
}
|
||||
|
||||
public String getKeyId() {
|
||||
return getRequired(this.header, "kid", String.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T getRequired(Map<String, Object> map, String key, Class<T> type) {
|
||||
Object value = map.get(key);
|
||||
if (value == null) {
|
||||
throw new CloudFoundryAuthorizationException(
|
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
|
||||
"Unable to get value from key " + key);
|
||||
}
|
||||
if (!type.isInstance(value)) {
|
||||
throw new CloudFoundryAuthorizationException(
|
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN,
|
||||
"Unexpected value type from key " + key + " value " + value);
|
||||
}
|
||||
return (T) value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return this.encoded;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,142 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
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 org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.util.Base64Utils;
|
||||
|
||||
/**
|
||||
* Validator used to ensure that a signed {@link Token} has not been tampered with.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class TokenValidator {
|
||||
|
||||
private final CloudFoundrySecurityService securityService;
|
||||
|
||||
private Map<String, String> tokenKeys;
|
||||
|
||||
TokenValidator(CloudFoundrySecurityService cloudFoundrySecurityService) {
|
||||
this.securityService = cloudFoundrySecurityService;
|
||||
}
|
||||
|
||||
public void validate(Token token) {
|
||||
validateAlgorithm(token);
|
||||
validateKeyIdAndSignature(token);
|
||||
validateExpiry(token);
|
||||
validateIssuer(token);
|
||||
validateAudience(token);
|
||||
}
|
||||
|
||||
private void validateAlgorithm(Token token) {
|
||||
String algorithm = token.getSignatureAlgorithm();
|
||||
if (algorithm == null) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
|
||||
"Signing algorithm cannot be null");
|
||||
}
|
||||
if (!algorithm.equals("RS256")) {
|
||||
throw new CloudFoundryAuthorizationException(
|
||||
Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM,
|
||||
"Signing algorithm " + algorithm + " not supported");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateKeyIdAndSignature(Token token) {
|
||||
String keyId = token.getKeyId();
|
||||
if (this.tokenKeys == null || !hasValidKeyId(keyId)) {
|
||||
this.tokenKeys = this.securityService.fetchTokenKeys();
|
||||
if (!hasValidKeyId(keyId)) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_KEY_ID,
|
||||
"Key Id present in token header does not match");
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidSignature(token, this.tokenKeys.get(keyId))) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
|
||||
"RSA Signature did not match content");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasValidKeyId(String tokenKey) {
|
||||
for (String candidate : this.tokenKeys.keySet()) {
|
||||
if (tokenKey.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 void validateExpiry(Token token) {
|
||||
long currentTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||
if (currentTime > token.getExpiry()) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.TOKEN_EXPIRED,
|
||||
"Token expired");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateIssuer(Token token) {
|
||||
String uaaUrl = this.securityService.getUaaUrl();
|
||||
String issuerUri = String.format("%s/oauth/token", uaaUrl);
|
||||
if (!issuerUri.equals(token.getIssuer())) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_ISSUER,
|
||||
"Token issuer does not match " + uaaUrl + "/oauth/token");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateAudience(Token token) {
|
||||
if (!token.getScope().contains("actuator.read")) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_AUDIENCE,
|
||||
"Token does not have audience actuator");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -2,6 +2,7 @@ org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
|
|||
org.springframework.boot.actuate.autoconfigure.ManagementContextAutoConfiguration,\
|
||||
org.springframework.boot.actuate.autoconfigure.audit.AuditAutoConfiguration,\
|
||||
org.springframework.boot.actuate.autoconfigure.cache.CacheStatisticsAutoConfiguration,\
|
||||
org.springframework.boot.actuate.cloudfoundry.CloudFoundryActuatorAutoConfiguration,\
|
||||
org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration,\
|
||||
org.springframework.boot.actuate.autoconfigure.endpoint.infrastructure.EndpointInfrastructureAutoConfiguration,\
|
||||
org.springframework.boot.actuate.autoconfigure.endpoint.infrastructure.ServletEndpointAutoConfiguration,\
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright 2012-2016 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.cloudfoundry;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link AccessLevel}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class AccessLevelTests {
|
||||
|
||||
@Test
|
||||
public void accessToHealthEndpointShouldNotBeRestricted() throws Exception {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("health")).isTrue();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("health")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessToInfoEndpointShouldNotBeRestricted() throws Exception {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("info")).isTrue();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("info")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessToDiscoveryEndpointShouldNotBeRestricted() throws Exception {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("")).isTrue();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void accessToAnyOtherEndpointShouldBeRestricted() throws Exception {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("env")).isFalse();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright 2012-2016 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.cloudfoundry;
|
||||
|
||||
import org.hamcrest.CustomMatcher;
|
||||
import org.hamcrest.Matcher;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
|
||||
/**
|
||||
* Hamcrest matcher to check the {@link AuthorizationExceptionMatcher} {@link Reason}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
final class AuthorizationExceptionMatcher {
|
||||
|
||||
private AuthorizationExceptionMatcher() {
|
||||
}
|
||||
|
||||
static Matcher<?> withReason(final Reason reason) {
|
||||
return new CustomMatcher<Object>(
|
||||
"CloudFoundryAuthorizationException with " + reason + " reason") {
|
||||
|
||||
@Override
|
||||
public boolean matches(Object object) {
|
||||
return ((object instanceof CloudFoundryAuthorizationException)
|
||||
&& ((CloudFoundryAuthorizationException) object)
|
||||
.getReason() == reason);
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,192 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
import org.junit.After;
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.infrastructure.EndpointInfrastructureAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.infrastructure.ServletEndpointAutoConfiguration;
|
||||
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.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.client.RestTemplateAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.web.servlet.WebMvcAutoConfiguration;
|
||||
import org.springframework.boot.context.properties.source.ConfigurationPropertySources;
|
||||
import org.springframework.boot.test.util.TestPropertyValues;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockServletContext;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.context.support.AnnotationConfigWebApplicationContext;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryActuatorAutoConfiguration}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class CloudFoundryActuatorAutoConfigurationTests {
|
||||
|
||||
private AnnotationConfigWebApplicationContext context;
|
||||
|
||||
@Before
|
||||
public void setup() {
|
||||
this.context = new AnnotationConfigWebApplicationContext();
|
||||
this.context.setServletContext(new MockServletContext());
|
||||
this.context.register(SecurityAutoConfiguration.class,
|
||||
WebMvcAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class,
|
||||
DispatcherServletAutoConfiguration.class,
|
||||
HttpMessageConvertersAutoConfiguration.class,
|
||||
PropertyPlaceholderAutoConfiguration.class,
|
||||
RestTemplateAutoConfiguration.class,
|
||||
EndpointInfrastructureAutoConfiguration.class,
|
||||
ServletEndpointAutoConfiguration.class,
|
||||
CloudFoundryActuatorAutoConfiguration.class);
|
||||
}
|
||||
|
||||
@After
|
||||
public void close() {
|
||||
if (this.context != null) {
|
||||
this.context.close();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloudFoundryPlatformActive() throws Exception {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping();
|
||||
assertThat(handlerMapping.getEndpointPath()).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 cloudFoundryPlatformActiveSetsApplicationId() throws Exception {
|
||||
CloudFoundryWebEndpointServletHandlerMapping 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 {
|
||||
CloudFoundryWebEndpointServletHandlerMapping 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 skipSslValidation() throws Exception {
|
||||
TestPropertyValues.of("management.cloudfoundry.skipSslValidation:true")
|
||||
.applyTo(this.context);
|
||||
ConfigurationPropertySources.attach(this.context.getEnvironment());
|
||||
this.context.refresh();
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping();
|
||||
Object interceptor = ReflectionTestUtils.getField(handlerMapping,
|
||||
"securityInterceptor");
|
||||
Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor,
|
||||
"cloudFoundrySecurityService");
|
||||
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils
|
||||
.getField(interceptorSecurityService, "restTemplate");
|
||||
assertThat(restTemplate.getRequestFactory())
|
||||
.isInstanceOf(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent()
|
||||
throws Exception {
|
||||
TestPropertyValues
|
||||
.of("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
|
||||
.applyTo(this.context);
|
||||
this.context.refresh();
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = this.context.getBean(
|
||||
"cloudFoundryWebEndpointServletHandlerMapping",
|
||||
CloudFoundryWebEndpointServletHandlerMapping.class);
|
||||
Object securityInterceptor = ReflectionTestUtils.getField(handlerMapping,
|
||||
"securityInterceptor");
|
||||
Object interceptorSecurityService = ReflectionTestUtils
|
||||
.getField(securityInterceptor, "cloudFoundrySecurityService");
|
||||
assertThat(interceptorSecurityService).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloudFoundryPathsIgnoredBySpringSecurity() throws Exception {
|
||||
TestPropertyValues
|
||||
.of("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
|
||||
.applyTo(this.context);
|
||||
this.context.refresh();
|
||||
FilterChainProxy securityFilterChain = (FilterChainProxy) this.context.getBean("springSecurityFilterChain");
|
||||
SecurityFilterChain chain = securityFilterChain.getFilterChains().get(0);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
request.setServletPath("/cloudfoundryapplication/my-path");
|
||||
assertThat(chain.getFilters()).isEmpty();
|
||||
assertThat(chain.matches(request)).isTrue();
|
||||
request.setServletPath("/some-other-path");
|
||||
assertThat(chain.matches(request)).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloudFoundryPlatformInactive() throws Exception {
|
||||
this.context.refresh();
|
||||
assertThat(this.context.containsBean("cloudFoundryWebEndpointServletHandlerMapping"))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void cloudFoundryManagementEndpointsDisabled() throws Exception {
|
||||
TestPropertyValues
|
||||
.of("VCAP_APPLICATION=---", "management.cloudfoundry.enabled:false")
|
||||
.applyTo(this.context);
|
||||
this.context.refresh();
|
||||
assertThat(this.context.containsBean("cloudFoundryEndpointHandlerMapping"))
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
private CloudFoundryWebEndpointServletHandlerMapping getHandlerMapping() {
|
||||
TestPropertyValues
|
||||
.of("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:http://my-cloud-controller.com")
|
||||
.applyTo(this.context);
|
||||
this.context.refresh();
|
||||
return this.context.getBean("cloudFoundryWebEndpointServletHandlerMapping",
|
||||
CloudFoundryWebEndpointServletHandlerMapping.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
/*
|
||||
* Copyright 2012-2016 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.cloudfoundry;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryAuthorizationException}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class CloudFoundryAuthorizationExceptionTests {
|
||||
|
||||
@Test
|
||||
public void statusCodeForInvalidTokenReasonShouldBe401() throws Exception {
|
||||
assertThat(createException(Reason.INVALID_TOKEN).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForInvalidIssuerReasonShouldBe401() throws Exception {
|
||||
assertThat(createException(Reason.INVALID_ISSUER).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForInvalidAudienceReasonShouldBe401() throws Exception {
|
||||
assertThat(createException(Reason.INVALID_AUDIENCE).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForInvalidSignatureReasonShouldBe401() throws Exception {
|
||||
assertThat(createException(Reason.INVALID_SIGNATURE).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForMissingAuthorizationReasonShouldBe401() throws Exception {
|
||||
assertThat(createException(Reason.MISSING_AUTHORIZATION).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForUnsupportedSignatureAlgorithmReasonShouldBe401()
|
||||
throws Exception {
|
||||
assertThat(createException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM)
|
||||
.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForTokenExpiredReasonShouldBe401() throws Exception {
|
||||
assertThat(createException(Reason.TOKEN_EXPIRED).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForAccessDeniedReasonShouldBe403() throws Exception {
|
||||
assertThat(createException(Reason.ACCESS_DENIED).getStatusCode())
|
||||
.isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void statusCodeForServiceUnavailableReasonShouldBe503() throws Exception {
|
||||
assertThat(createException(Reason.SERVICE_UNAVAILABLE).getStatusCode())
|
||||
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private CloudFoundryAuthorizationException createException(Reason reason) {
|
||||
return new CloudFoundryAuthorizationException(reason, "message");
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,305 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
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 org.mockito.Mockito;
|
||||
|
||||
import org.springframework.boot.endpoint.CachingConfiguration;
|
||||
import org.springframework.boot.endpoint.ConversionServiceOperationParameterMapper;
|
||||
import org.springframework.boot.endpoint.Endpoint;
|
||||
import org.springframework.boot.endpoint.OperationParameterMapper;
|
||||
import org.springframework.boot.endpoint.ReadOperation;
|
||||
import org.springframework.boot.endpoint.Selector;
|
||||
import org.springframework.boot.endpoint.WriteOperation;
|
||||
import org.springframework.boot.endpoint.web.WebAnnotationEndpointDiscoverer;
|
||||
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
|
||||
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
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.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.util.Base64Utils;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.DispatcherServlet;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Integration tests for web endpoints exposed using Spring MVC
|
||||
* on CloudFoundry.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class CloudFoundryMvcWebEndpointIntegrationTests {
|
||||
|
||||
private static TokenValidator tokenValidator = Mockito.mock(TokenValidator.class);
|
||||
|
||||
private static CloudFoundrySecurityService securityService = Mockito.mock(CloudFoundrySecurityService.class);
|
||||
|
||||
@Test
|
||||
public void operationWithSecurityInterceptorForbidden() throws Exception {
|
||||
BDDMockito.doNothing().when(tokenValidator).validate(any());
|
||||
given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(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 {
|
||||
BDDMockito.doNothing().when(tokenValidator).validate(any());
|
||||
given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(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() {
|
||||
BDDMockito.doNothing().when(tokenValidator).validate(any());
|
||||
given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(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(CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, "invalid-token");
|
||||
BDDMockito.doThrow(exception).when(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() {
|
||||
BDDMockito.doNothing().when(tokenValidator).validate(any());
|
||||
given(securityService.getAccessLevel(any(), eq("app-id"))).willReturn(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 AnnotationConfigServletWebServerApplicationContext createApplicationContext(Class<?>... config) {
|
||||
return new AnnotationConfigServletWebServerApplicationContext(config);
|
||||
}
|
||||
|
||||
private int getPort(AnnotationConfigServletWebServerApplicationContext context) {
|
||||
return context.getWebServer().getPort();
|
||||
}
|
||||
|
||||
private void load(Class<?> configuration, Consumer<WebTestClient> clientConsumer) {
|
||||
BiConsumer<ApplicationContext, WebTestClient> consumer = (context, client) -> clientConsumer.accept(client);
|
||||
AnnotationConfigServletWebServerApplicationContext context = createApplicationContext(configuration, CloudFoundryMvcConfiguration.class);
|
||||
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
|
||||
@EnableWebMvc
|
||||
static class CloudFoundryMvcConfiguration {
|
||||
|
||||
@Bean
|
||||
public CloudFoundrySecurityInterceptor interceptor() {
|
||||
return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
|
||||
}
|
||||
|
||||
@Bean
|
||||
public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
|
||||
WebAnnotationEndpointDiscoverer webEndpointDiscoverer, CloudFoundrySecurityInterceptor interceptor) {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.setAllowedOrigins(Arrays.asList("http://example.com"));
|
||||
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST"));
|
||||
return new CloudFoundryWebEndpointServletHandlerMapping("/cfApplication",
|
||||
webEndpointDiscoverer.discoverEndpoints(), corsConfiguration, interceptor);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public WebAnnotationEndpointDiscoverer webEndpointDiscoverer(
|
||||
ApplicationContext applicationContext) {
|
||||
OperationParameterMapper parameterMapper = new ConversionServiceOperationParameterMapper(
|
||||
DefaultConversionService.getSharedInstance());
|
||||
return new WebAnnotationEndpointDiscoverer(applicationContext,
|
||||
parameterMapper, (id) -> new CachingConfiguration(0),
|
||||
Collections.singletonList("application/json"),
|
||||
Collections.singletonList("application/json"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
public EndpointDelegate endpointDelegate() {
|
||||
return mock(EndpointDelegate.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public TomcatServletWebServerFactory tomcat() {
|
||||
return new TomcatServletWebServerFactory(0);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public DispatcherServlet dispatcherServlet() {
|
||||
return new DispatcherServlet();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@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(CloudFoundryMvcConfiguration.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,154 @@
|
|||
/*
|
||||
* Copyright 2012-2016 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.cloudfoundry;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.BDDMockito;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.util.Base64Utils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundrySecurityInterceptor}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class CloudFoundrySecurityInterceptorTests {
|
||||
|
||||
@Mock
|
||||
private TokenValidator tokenValidator;
|
||||
|
||||
@Mock
|
||||
private CloudFoundrySecurityService securityService;
|
||||
|
||||
private CloudFoundrySecurityInterceptor interceptor;
|
||||
|
||||
private MockHttpServletRequest request;
|
||||
|
||||
@Before
|
||||
public void setup() throws Exception {
|
||||
MockitoAnnotations.initMocks(this);
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator,
|
||||
this.securityService, "my-app-id");
|
||||
this.request = new MockHttpServletRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleWhenRequestIsPreFlightShouldReturnTrue() throws Exception {
|
||||
this.request.setMethod("OPTIONS");
|
||||
this.request.addHeader(HttpHeaders.ORIGIN, "http://example.com");
|
||||
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleWhenTokenIsMissingShouldReturnFalse() throws Exception {
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
assertThat(response.getStatus())
|
||||
.isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleWhenTokenIsNotBearerShouldReturnFalse() throws Exception {
|
||||
this.request.addHeader("Authorization", mockAccessToken());
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
assertThat(response.getStatus())
|
||||
.isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleWhenApplicationIdIsNullShouldReturnFalse() throws Exception {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator,
|
||||
this.securityService, null);
|
||||
this.request.addHeader("Authorization", "bearer " + mockAccessToken());
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
assertThat(response.getStatus())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnFalse()
|
||||
throws Exception {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null,
|
||||
"my-app-id");
|
||||
this.request.addHeader("Authorization", "bearer " + mockAccessToken());
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
assertThat(response.getStatus())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleWhenAccessIsNotAllowedShouldReturnFalse() throws Exception {
|
||||
String accessToken = mockAccessToken();
|
||||
this.request.addHeader("Authorization", "bearer " + accessToken);
|
||||
BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id"))
|
||||
.willReturn(AccessLevel.RESTRICTED);
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
assertThat(response.getStatus())
|
||||
.isEqualTo(Reason.ACCESS_DENIED.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleSuccessfulWithFullAccess() throws Exception {
|
||||
String accessToken = mockAccessToken();
|
||||
this.request.addHeader("Authorization", "Bearer " + accessToken);
|
||||
BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id"))
|
||||
.willReturn(AccessLevel.FULL);
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "/a");
|
||||
ArgumentCaptor<Token> tokenArgumentCaptor = ArgumentCaptor.forClass(Token.class);
|
||||
verify(this.tokenValidator).validate(tokenArgumentCaptor.capture());
|
||||
Token token = tokenArgumentCaptor.getValue();
|
||||
assertThat(token.toString()).isEqualTo(accessToken);
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(this.request.getAttribute("cloudFoundryAccessLevel"))
|
||||
.isEqualTo(AccessLevel.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void preHandleSuccessfulWithRestrictedAccess() throws Exception {
|
||||
String accessToken = mockAccessToken();
|
||||
this.request.addHeader("Authorization", "Bearer " + accessToken);
|
||||
BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id"))
|
||||
.willReturn(AccessLevel.RESTRICTED);
|
||||
CloudFoundrySecurityInterceptor.SecurityResponse response = this.interceptor.preHandle(this.request, "info");
|
||||
ArgumentCaptor<Token> tokenArgumentCaptor = ArgumentCaptor.forClass(Token.class);
|
||||
verify(this.tokenValidator).validate(tokenArgumentCaptor.capture());
|
||||
Token token = tokenArgumentCaptor.getValue();
|
||||
assertThat(token.toString()).isEqualTo(accessToken);
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(this.request.getAttribute("cloudFoundryAccessLevel"))
|
||||
.isEqualTo(AccessLevel.RESTRICTED);
|
||||
}
|
||||
|
||||
private String mockAccessToken() {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
|
||||
+ Base64Utils.encodeToString("signature".getBytes());
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,219 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.Before;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.ExpectedException;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.test.web.client.MockServerRestTemplateCustomizer;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.header;
|
||||
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withServerError;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withStatus;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
|
||||
import static org.springframework.test.web.client.response.MockRestResponseCreators.withUnauthorizedRequest;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundrySecurityService}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class CloudFoundrySecurityServiceTests {
|
||||
|
||||
@Rule
|
||||
public ExpectedException thrown = ExpectedException.none();
|
||||
|
||||
private static final String CLOUD_CONTROLLER = "http://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-uaa.com";
|
||||
|
||||
private CloudFoundrySecurityService securityService;
|
||||
|
||||
private MockRestServiceServer server;
|
||||
|
||||
@Before
|
||||
public void setup() throws Exception {
|
||||
MockServerRestTemplateCustomizer mockServerCustomizer = new MockServerRestTemplateCustomizer();
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder(mockServerCustomizer);
|
||||
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER,
|
||||
false);
|
||||
this.server = mockServerCustomizer.getServer();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void skipSslValidationWhenTrue() throws Exception {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder();
|
||||
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER,
|
||||
true);
|
||||
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils
|
||||
.getField(this.securityService, "restTemplate");
|
||||
assertThat(restTemplate.getRequestFactory())
|
||||
.isInstanceOf(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void doNotskipSslValidationWhenFalse() throws Exception {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder();
|
||||
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER,
|
||||
false);
|
||||
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils
|
||||
.getField(this.securityService, "restTemplate");
|
||||
assertThat(restTemplate.getRequestFactory())
|
||||
.isNotInstanceOf(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccessLevelWhenSpaceDeveloperShouldReturnFull() throws Exception {
|
||||
String responseBody = "{\"read_sensitive_data\": true,\"read_basic_data\": true}";
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
|
||||
AccessLevel accessLevel = this.securityService.getAccessLevel("my-access-token",
|
||||
"my-app-id");
|
||||
this.server.verify();
|
||||
assertThat(accessLevel).isEqualTo(AccessLevel.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccessLevelWhenNotSpaceDeveloperShouldReturnRestricted()
|
||||
throws Exception {
|
||||
String responseBody = "{\"read_sensitive_data\": false,\"read_basic_data\": true}";
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
|
||||
AccessLevel accessLevel = this.securityService.getAccessLevel("my-access-token",
|
||||
"my-app-id");
|
||||
this.server.verify();
|
||||
assertThat(accessLevel).isEqualTo(AccessLevel.RESTRICTED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccessLevelWhenTokenIsNotValidShouldThrowException() throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withUnauthorizedRequest());
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
this.securityService.getAccessLevel("my-access-token", "my-app-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccessLevelWhenForbiddenShouldThrowException() throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withStatus(HttpStatus.FORBIDDEN));
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.ACCESS_DENIED));
|
||||
this.securityService.getAccessLevel("my-access-token", "my-app-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getAccessLevelWhenCloudControllerIsNotReachableThrowsException()
|
||||
throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withServerError());
|
||||
this.thrown.expect(
|
||||
AuthorizationExceptionMatcher.withReason(Reason.SERVICE_UNAVAILABLE));
|
||||
this.securityService.getAccessLevel("my-access-token", "my-app-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fetchTokenKeysWhenSuccessfulShouldReturnListOfKeysFromUAA()
|
||||
throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
|
||||
.andRespond(withSuccess("{\"token_endpoint\":\"http://my-uaa.com\"}",
|
||||
MediaType.APPLICATION_JSON));
|
||||
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-----";
|
||||
String responseBody = "{\"keys\" : [ {\"kid\":\"test-key\",\"value\" : \""
|
||||
+ tokenKeyValue.replace("\n", "\\n") + "\"} ]}";
|
||||
this.server.expect(requestTo(UAA_URL + "/token_keys"))
|
||||
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
|
||||
Map<String, String> tokenKeys = this.securityService.fetchTokenKeys();
|
||||
this.server.verify();
|
||||
assertThat(tokenKeys.get("test-key")).isEqualTo(tokenKeyValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fetchTokenKeysWhenNoKeysReturnedFromUAA() throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withSuccess(
|
||||
"{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
|
||||
String responseBody = "{\"keys\": []}";
|
||||
this.server.expect(requestTo(UAA_URL + "/token_keys"))
|
||||
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
|
||||
Map<String, String> tokenKeys = this.securityService.fetchTokenKeys();
|
||||
this.server.verify();
|
||||
assertThat(tokenKeys).hasSize(0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void fetchTokenKeysWhenUnsuccessfulShouldThrowException() throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withSuccess(
|
||||
"{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
|
||||
this.server.expect(requestTo(UAA_URL + "/token_keys"))
|
||||
.andRespond(withServerError());
|
||||
this.thrown.expect(
|
||||
AuthorizationExceptionMatcher.withReason(Reason.SERVICE_UNAVAILABLE));
|
||||
this.securityService.fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withSuccess(
|
||||
"{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
|
||||
String uaaUrl = this.securityService.getUaaUrl();
|
||||
this.server.verify();
|
||||
assertThat(uaaUrl).isEqualTo(UAA_URL);
|
||||
// Second call should not need to hit server
|
||||
uaaUrl = this.securityService.getUaaUrl();
|
||||
assertThat(uaaUrl).isEqualTo(UAA_URL);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException()
|
||||
throws Exception {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
|
||||
.andRespond(withServerError());
|
||||
this.thrown.expect(
|
||||
AuthorizationExceptionMatcher.withReason(Reason.SERVICE_UNAVAILABLE));
|
||||
this.securityService.getUaaUrl();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,93 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
|
||||
import org.hamcrest.Matcher;
|
||||
import org.junit.After;
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.ExpectedException;
|
||||
|
||||
import org.springframework.boot.testsupport.web.servlet.ExampleServlet;
|
||||
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
|
||||
import org.springframework.boot.web.server.Ssl;
|
||||
import org.springframework.boot.web.server.WebServer;
|
||||
import org.springframework.boot.web.servlet.ServletRegistrationBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.hamcrest.Matchers.instanceOf;
|
||||
|
||||
/**
|
||||
* Test for {@link SkipSslVerificationHttpRequestFactory}.
|
||||
*/
|
||||
public class SkipSslVerificationHttpRequestFactoryTests {
|
||||
|
||||
@Rule
|
||||
public ExpectedException thrown = ExpectedException.none();
|
||||
|
||||
private WebServer webServer;
|
||||
|
||||
@After
|
||||
public void shutdownContainer() {
|
||||
if (this.webServer != null) {
|
||||
this.webServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
public void restCallToSelfSignedServerShouldNotThrowSslException() throws Exception {
|
||||
String httpsUrl = getHttpsUrl();
|
||||
SkipSslVerificationHttpRequestFactory requestFactory = new SkipSslVerificationHttpRequestFactory();
|
||||
RestTemplate restTemplate = new RestTemplate(requestFactory);
|
||||
ResponseEntity<String> responseEntity = restTemplate.getForEntity(httpsUrl,
|
||||
String.class);
|
||||
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
this.thrown.expect(ResourceAccessException.class);
|
||||
this.thrown.expectCause(isSSLHandshakeException());
|
||||
RestTemplate otherRestTemplate = new RestTemplate();
|
||||
otherRestTemplate.getForEntity(httpsUrl, String.class);
|
||||
}
|
||||
|
||||
private Matcher<Throwable> isSSLHandshakeException() {
|
||||
return instanceOf(SSLHandshakeException.class);
|
||||
}
|
||||
|
||||
private String getHttpsUrl() {
|
||||
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0);
|
||||
factory.setSsl(getSsl("password", "classpath:test.jks"));
|
||||
this.webServer = factory.getWebServer(
|
||||
new ServletRegistrationBean<>(new ExampleServlet(), "/hello"));
|
||||
this.webServer.start();
|
||||
return "https://localhost:" + this.webServer.getPort() + "/hello";
|
||||
}
|
||||
|
||||
private Ssl getSsl(String keyPassword, String keyStore) {
|
||||
Ssl ssl = new Ssl();
|
||||
ssl.setEnabled(true);
|
||||
ssl.setKeyPassword(keyPassword);
|
||||
ssl.setKeyStore(keyStore);
|
||||
ssl.setKeyStorePassword("secret");
|
||||
return ssl;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -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.cloudfoundry;
|
||||
|
||||
import org.junit.Rule;
|
||||
import org.junit.Test;
|
||||
import org.junit.rules.ExpectedException;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.util.Base64Utils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link Token}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class TokenTests {
|
||||
|
||||
@Rule
|
||||
public ExpectedException thrown = ExpectedException.none();
|
||||
|
||||
@Test
|
||||
public void invalidJwtShouldThrowException() throws Exception {
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
new Token("invalid-token");
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidJwtClaimsShouldThrowException() throws Exception {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "invalid-claims";
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
new Token(Base64Utils.encodeToString(header.getBytes()) + "."
|
||||
+ Base64Utils.encodeToString(claims.getBytes()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void invalidJwtHeaderShouldThrowException() throws Exception {
|
||||
String header = "invalid-header";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
new Token(Base64Utils.encodeToString(header.getBytes()) + "."
|
||||
+ Base64Utils.encodeToString(claims.getBytes()));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyJwtSignatureShouldThrowException() throws Exception {
|
||||
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ.";
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
new Token(token);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validJwt() throws Exception {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
String content = Base64Utils.encodeToString(header.getBytes()) + "."
|
||||
+ Base64Utils.encodeToString(claims.getBytes());
|
||||
String signature = Base64Utils.encodeToString("signature".getBytes());
|
||||
Token token = new Token(content + "." + signature);
|
||||
assertThat(token.getExpiry()).isEqualTo(2147483647);
|
||||
assertThat(token.getIssuer()).isEqualTo("http://localhost:8080/uaa/oauth/token");
|
||||
assertThat(token.getSignatureAlgorithm()).isEqualTo("RS256");
|
||||
assertThat(token.getKeyId()).isEqualTo("key-id");
|
||||
assertThat(token.getContent()).isEqualTo(content.getBytes());
|
||||
assertThat(token.getSignature())
|
||||
.isEqualTo(Base64Utils.decodeFromString(signature));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getSignatureAlgorithmWhenAlgIsNullShouldThrowException()
|
||||
throws Exception {
|
||||
String header = "{\"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
Token token = createToken(header, claims);
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
token.getSignatureAlgorithm();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getIssuerWhenIssIsNullShouldThrowException() throws Exception {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647}";
|
||||
Token token = createToken(header, claims);
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
token.getIssuer();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getKidWhenKidIsNullShouldThrowException() throws Exception {
|
||||
String header = "{\"alg\": \"RS256\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647}";
|
||||
Token token = createToken(header, claims);
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
token.getKeyId();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void getExpiryWhenExpIsNullShouldThrowException() throws Exception {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"iss\": \"http://localhost:8080/uaa/oauth/token\"" + "}";
|
||||
Token token = createToken(header, claims);
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN));
|
||||
token.getExpiry();
|
||||
}
|
||||
|
||||
private Token createToken(String header, String claims) {
|
||||
Token token = new Token(Base64Utils.encodeToString(header.getBytes()) + "."
|
||||
+ Base64Utils.encodeToString(claims.getBytes()) + "."
|
||||
+ Base64Utils.encodeToString("signature".getBytes()));
|
||||
return token;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,269 @@
|
|||
/*
|
||||
* 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.cloudfoundry;
|
||||
|
||||
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.Mockito;
|
||||
import org.mockito.MockitoAnnotations;
|
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.Base64Utils;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.verify;
|
||||
|
||||
/**
|
||||
* Tests for {@link TokenValidator}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
public class TokenValidatorTests {
|
||||
|
||||
private static final byte[] DOT = ".".getBytes();
|
||||
|
||||
private static final Charset UTF_8 = Charset.forName("UTF-8");
|
||||
|
||||
@Rule
|
||||
public ExpectedException thrown = ExpectedException.none();
|
||||
|
||||
@Mock
|
||||
private CloudFoundrySecurityService securityService;
|
||||
|
||||
private TokenValidator 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 TokenValidator(this.securityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenKidValidationFailsTwiceShouldThrowException()
|
||||
throws Exception {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(INVALID_KEYS);
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_KEY_ID));
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenKidValidationSucceedsInTheSecondAttempt()
|
||||
throws Exception {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("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\"]}";
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
verify(this.securityService).fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenShouldFetchTokenKeysIfNull() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("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\"]}";
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
verify(this.securityService).fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenValidShouldNotFetchTokenKeys() throws Exception {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("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\"]}";
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
verify(this.securityService, Mockito.never()).fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenSignatureInvalidShouldThrowException() throws Exception {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys",
|
||||
Collections.singletonMap("valid-key", INVALID_KEY));
|
||||
given(this.securityService.getUaaUrl()).willReturn("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\"]}";
|
||||
this.thrown.expect(
|
||||
AuthorizationExceptionMatcher.withReason(Reason.INVALID_SIGNATURE));
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenTokenAlgorithmIsNotRS256ShouldThrowException()
|
||||
throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
String header = "{ \"alg\": \"HS256\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
this.thrown.expect(AuthorizationExceptionMatcher
|
||||
.withReason(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM));
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenExpiredShouldThrowException() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"jti\": \"0236399c350c47f3ae77e67a75e75e7d\", \"exp\": 1477509977, \"scope\": [\"actuator.read\"]}";
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.TOKEN_EXPIRED));
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenIssuerIsNotValidShouldThrowException() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("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\"}";
|
||||
this.thrown
|
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_ISSUER));
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void validateTokenWhenAudienceIsNotValidShouldThrowException()
|
||||
throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("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\"]}";
|
||||
this.thrown.expect(
|
||||
AuthorizationExceptionMatcher.withReason(Reason.INVALID_AUDIENCE));
|
||||
this.tokenValidator.validate(
|
||||
new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
/*
|
||||
* 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.endpoint.web.mvc;
|
||||
|
||||
import java.lang.reflect.Method;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.endpoint.EndpointInfo;
|
||||
import org.springframework.boot.endpoint.web.OperationRequestPredicate;
|
||||
import org.springframework.boot.endpoint.web.WebEndpointOperation;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.accept.PathExtensionContentNegotiationStrategy;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
|
||||
import org.springframework.web.servlet.mvc.condition.ConsumesRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
|
||||
|
||||
/**
|
||||
* A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available
|
||||
* over HTTP using Spring MVC.
|
||||
*
|
||||
* @author Andy Wilkinson
|
||||
* @author Madhura Bhave
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public abstract class AbstractWebEndpointServletHandlerMapping extends RequestMappingInfoHandlerMapping
|
||||
implements InitializingBean {
|
||||
|
||||
private final String endpointPath;
|
||||
|
||||
private final Collection<EndpointInfo<WebEndpointOperation>> webEndpoints;
|
||||
|
||||
private final CorsConfiguration corsConfiguration;
|
||||
|
||||
/**
|
||||
* Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the
|
||||
* operations of the given {@code webEndpoints}.
|
||||
* @param endpointPath the path beneath which all endpoints should be mapped
|
||||
* @param collection the web endpoints operations
|
||||
*/
|
||||
public AbstractWebEndpointServletHandlerMapping(String endpointPath,
|
||||
Collection<EndpointInfo<WebEndpointOperation>> collection) {
|
||||
this(endpointPath, collection, null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the
|
||||
* operations of the given {@code webEndpoints}.
|
||||
* @param endpointPath the path beneath which all endpoints should be mapped
|
||||
* @param webEndpoints the web endpoints
|
||||
* @param corsConfiguration the CORS configuraton for the endpoints
|
||||
*/
|
||||
public AbstractWebEndpointServletHandlerMapping(String endpointPath,
|
||||
Collection<EndpointInfo<WebEndpointOperation>> webEndpoints,
|
||||
CorsConfiguration corsConfiguration) {
|
||||
this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath;
|
||||
this.webEndpoints = webEndpoints;
|
||||
this.corsConfiguration = corsConfiguration;
|
||||
setOrder(-100);
|
||||
}
|
||||
|
||||
public Collection<EndpointInfo<WebEndpointOperation>> getEndpoints() {
|
||||
return this.webEndpoints;
|
||||
}
|
||||
|
||||
public String getEndpointPath() {
|
||||
return this.endpointPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initHandlerMethods() {
|
||||
this.webEndpoints.stream()
|
||||
.flatMap((webEndpoint) -> webEndpoint.getOperations().stream())
|
||||
.forEach(this::registerMappingForOperation);
|
||||
registerMapping(new RequestMappingInfo(patternsRequestConditionForPattern(""),
|
||||
new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null,
|
||||
null, null), this, getLinks());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CorsConfiguration initCorsConfiguration(Object handler, Method method,
|
||||
RequestMappingInfo mapping) {
|
||||
return this.corsConfiguration;
|
||||
}
|
||||
|
||||
protected abstract Method getLinks();
|
||||
|
||||
protected abstract void registerMappingForOperation(WebEndpointOperation operation);
|
||||
|
||||
protected RequestMappingInfo createRequestMappingInfo(
|
||||
WebEndpointOperation operationInfo) {
|
||||
OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate();
|
||||
return new RequestMappingInfo(null,
|
||||
patternsRequestConditionForPattern(requestPredicate.getPath()),
|
||||
new RequestMethodsRequestCondition(
|
||||
RequestMethod.valueOf(requestPredicate.getHttpMethod().name())),
|
||||
null, null,
|
||||
new ConsumesRequestCondition(
|
||||
toStringArray(requestPredicate.getConsumes())),
|
||||
new ProducesRequestCondition(
|
||||
toStringArray(requestPredicate.getProduces())),
|
||||
null);
|
||||
}
|
||||
|
||||
private PatternsRequestCondition patternsRequestConditionForPattern(String path) {
|
||||
return new PatternsRequestCondition(
|
||||
new String[] { this.endpointPath
|
||||
+ (StringUtils.hasText(path) ? "/" + path : "") },
|
||||
null, null, false, false);
|
||||
}
|
||||
|
||||
private String[] toStringArray(Collection<String> collection) {
|
||||
return collection.toArray(new String[collection.size()]);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isHandler(Class<?> beanType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestMappingInfo getMappingForMethod(Method method,
|
||||
Class<?> handlerType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void extendInterceptors(List<Object> interceptors) {
|
||||
interceptors.add(new SkipPathExtensionContentNegotiation());
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link HandlerInterceptorAdapter} to ensure that
|
||||
* {@link PathExtensionContentNegotiationStrategy} is skipped for web endpoints.
|
||||
*/
|
||||
private static final class SkipPathExtensionContentNegotiation
|
||||
extends HandlerInterceptorAdapter {
|
||||
|
||||
private static final String SKIP_ATTRIBUTE = PathExtensionContentNegotiationStrategy.class
|
||||
.getName() + ".SKIP";
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler) throws Exception {
|
||||
request.setAttribute(SKIP_ATTRIBUTE, Boolean.TRUE);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -21,38 +21,25 @@ import java.util.Arrays;
|
|||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
|
||||
import org.springframework.beans.factory.InitializingBean;
|
||||
import org.springframework.boot.endpoint.EndpointInfo;
|
||||
import org.springframework.boot.endpoint.OperationInvoker;
|
||||
import org.springframework.boot.endpoint.ParameterMappingException;
|
||||
import org.springframework.boot.endpoint.web.EndpointLinksResolver;
|
||||
import org.springframework.boot.endpoint.web.Link;
|
||||
import org.springframework.boot.endpoint.web.OperationRequestPredicate;
|
||||
import org.springframework.boot.endpoint.web.WebEndpointOperation;
|
||||
import org.springframework.boot.endpoint.web.WebEndpointResponse;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.util.ReflectionUtils;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.accept.PathExtensionContentNegotiationStrategy;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMethod;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.HandlerMapping;
|
||||
import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
|
||||
import org.springframework.web.servlet.mvc.condition.ConsumesRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.condition.PatternsRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.condition.ProducesRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.condition.RequestMethodsRequestCondition;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfo;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
|
||||
|
||||
/**
|
||||
|
|
@ -62,8 +49,7 @@ import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMappi
|
|||
* @author Andy Wilkinson
|
||||
* @since 2.0.0
|
||||
*/
|
||||
public class WebEndpointServletHandlerMapping extends RequestMappingInfoHandlerMapping
|
||||
implements InitializingBean {
|
||||
public class WebEndpointServletHandlerMapping extends AbstractWebEndpointServletHandlerMapping {
|
||||
|
||||
private final Method handle = ReflectionUtils.findMethod(OperationHandler.class,
|
||||
"handle", HttpServletRequest.class, Map.class);
|
||||
|
|
@ -73,12 +59,6 @@ public class WebEndpointServletHandlerMapping extends RequestMappingInfoHandlerM
|
|||
|
||||
private final EndpointLinksResolver endpointLinksResolver = new EndpointLinksResolver();
|
||||
|
||||
private final String endpointPath;
|
||||
|
||||
private final Collection<EndpointInfo<WebEndpointOperation>> webEndpoints;
|
||||
|
||||
private final CorsConfiguration corsConfiguration;
|
||||
|
||||
/**
|
||||
* Creates a new {@code WebEndpointHandlerMapping} that provides mappings for the
|
||||
* operations of the given {@code webEndpoints}.
|
||||
|
|
@ -100,87 +80,23 @@ public class WebEndpointServletHandlerMapping extends RequestMappingInfoHandlerM
|
|||
public WebEndpointServletHandlerMapping(String endpointPath,
|
||||
Collection<EndpointInfo<WebEndpointOperation>> webEndpoints,
|
||||
CorsConfiguration corsConfiguration) {
|
||||
this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath;
|
||||
this.webEndpoints = webEndpoints;
|
||||
this.corsConfiguration = corsConfiguration;
|
||||
super(endpointPath, webEndpoints, corsConfiguration);
|
||||
setOrder(-100);
|
||||
}
|
||||
|
||||
public Collection<EndpointInfo<WebEndpointOperation>> getEndpoints() {
|
||||
return this.webEndpoints;
|
||||
}
|
||||
|
||||
public String getEndpointPath() {
|
||||
return this.endpointPath;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void initHandlerMethods() {
|
||||
this.webEndpoints.stream()
|
||||
.flatMap((webEndpoint) -> webEndpoint.getOperations().stream())
|
||||
.forEach(this::registerMappingForOperation);
|
||||
registerMapping(new RequestMappingInfo(patternsRequestConditionForPattern(""),
|
||||
new RequestMethodsRequestCondition(RequestMethod.GET), null, null, null,
|
||||
null, null), this, this.links);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected CorsConfiguration initCorsConfiguration(Object handler, Method method,
|
||||
RequestMappingInfo mapping) {
|
||||
return this.corsConfiguration;
|
||||
}
|
||||
|
||||
private void registerMappingForOperation(WebEndpointOperation operation) {
|
||||
protected void registerMappingForOperation(WebEndpointOperation operation) {
|
||||
registerMapping(createRequestMappingInfo(operation),
|
||||
new OperationHandler(operation.getOperationInvoker()), this.handle);
|
||||
}
|
||||
|
||||
private RequestMappingInfo createRequestMappingInfo(
|
||||
WebEndpointOperation operationInfo) {
|
||||
OperationRequestPredicate requestPredicate = operationInfo.getRequestPredicate();
|
||||
return new RequestMappingInfo(null,
|
||||
patternsRequestConditionForPattern(requestPredicate.getPath()),
|
||||
new RequestMethodsRequestCondition(
|
||||
RequestMethod.valueOf(requestPredicate.getHttpMethod().name())),
|
||||
null, null,
|
||||
new ConsumesRequestCondition(
|
||||
toStringArray(requestPredicate.getConsumes())),
|
||||
new ProducesRequestCondition(
|
||||
toStringArray(requestPredicate.getProduces())),
|
||||
null);
|
||||
}
|
||||
|
||||
private PatternsRequestCondition patternsRequestConditionForPattern(String path) {
|
||||
return new PatternsRequestCondition(
|
||||
new String[] { this.endpointPath
|
||||
+ (StringUtils.hasText(path) ? "/" + path : "") },
|
||||
null, null, false, false);
|
||||
}
|
||||
|
||||
private String[] toStringArray(Collection<String> collection) {
|
||||
return collection.toArray(new String[collection.size()]);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isHandler(Class<?> beanType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected RequestMappingInfo getMappingForMethod(Method method,
|
||||
Class<?> handlerType) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void extendInterceptors(List<Object> interceptors) {
|
||||
interceptors.add(new SkipPathExtensionContentNegotiation());
|
||||
protected Method getLinks() {
|
||||
return this.links;
|
||||
}
|
||||
|
||||
@ResponseBody
|
||||
private Map<String, Map<String, Link>> links(HttpServletRequest request) {
|
||||
return Collections.singletonMap("_links", this.endpointLinksResolver
|
||||
.resolveLinks(this.webEndpoints, request.getRequestURL().toString()));
|
||||
.resolveLinks(getEndpoints(), request.getRequestURL().toString()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -229,23 +145,4 @@ public class WebEndpointServletHandlerMapping extends RequestMappingInfoHandlerM
|
|||
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link HandlerInterceptorAdapter} to ensure that
|
||||
* {@link PathExtensionContentNegotiationStrategy} is skipped for web endpoints.
|
||||
*/
|
||||
private static final class SkipPathExtensionContentNegotiation
|
||||
extends HandlerInterceptorAdapter {
|
||||
|
||||
private static final String SKIP_ATTRIBUTE = PathExtensionContentNegotiationStrategy.class
|
||||
.getName() + ".SKIP";
|
||||
|
||||
@Override
|
||||
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
|
||||
Object handler) throws Exception {
|
||||
request.setAttribute(SKIP_ATTRIBUTE, Boolean.TRUE);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue