diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/AccessLevel.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/AccessLevel.java new file mode 100644 index 0000000000..3aa73fa957 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/AccessLevel.java @@ -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 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); + } + +} diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java new file mode 100644 index 0000000000..bf1e66197d --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryActuatorAutoConfiguration.java @@ -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 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 { + + @Override + public void init(WebSecurity builder) throws Exception { + builder.ignoring().requestMatchers(new AntPathRequestMatcher("/cloudfoundryapplication/**")); + } + + @Override + public void configure(WebSecurity builder) throws Exception { + + } + + } + +} diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationException.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationException.java new file mode 100644 index 0000000000..d45e7601de --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationException.java @@ -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; + } + + } + +} diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptor.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptor.java new file mode 100644 index 0000000000..b918bd080c --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptor.java @@ -0,0 +1,141 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.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); + } + } + +} + diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityService.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityService.java new file mode 100644 index 0000000000..e0b8a3a314 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityService.java @@ -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 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 extractTokenKeys(Map response) { + Map tokenKeys = new HashMap(); + for (Object key : (List) response.get("keys")) { + Map tokenKey = (Map) key; + tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value")); + } + return tokenKeys; + } + + /** + * Return 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; + } + +} diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java new file mode 100644 index 0000000000..34079b62e7 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryWebEndpointServletHandlerMapping.java @@ -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> webEndpoints, CorsConfiguration corsConfiguration, CloudFoundrySecurityInterceptor securityInterceptor) { + super(endpointPath, webEndpoints, corsConfiguration); + this.securityInterceptor = securityInterceptor; + } + + @Override + protected Method getLinks() { + return this.links; + } + + @ResponseBody + private Map> 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 links = this.endpointLinksResolver + .resolveLinks(getEndpoints(), request.getRequestURL().toString()); + Map 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 body) { + CloudFoundrySecurityInterceptor.SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId); + if (!securityResponse.getStatus().equals(HttpStatus.OK)) { + return failureResponse(securityResponse); + } + Map arguments = new HashMap<>((Map) 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(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(response.getBody(), + HttpStatus.valueOf(response.getStatus())); + } + + } + +} + diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/SkipSslVerificationHttpRequestFactory.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/SkipSslVerificationHttpRequestFactory.java new file mode 100644 index 0000000000..dfc3c8d7cd --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/SkipSslVerificationHttpRequestFactory.java @@ -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) { + } + + } + +} + diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/Token.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/Token.java new file mode 100644 index 0000000000..6aa8846cbb --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/Token.java @@ -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 header; + + private final Map 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 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 getScope() { + return getRequired(this.claims, "scope", List.class); + } + + public String getKeyId() { + return getRequired(this.header, "kid", String.class); + } + + @SuppressWarnings("unchecked") + private T getRequired(Map map, String key, Class 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; + }; + +} + diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/TokenValidator.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/TokenValidator.java new file mode 100644 index 0000000000..41c9f74a4f --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/TokenValidator.java @@ -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 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"); + } + + } + +} + diff --git a/spring-boot-actuator/src/main/resources/META-INF/spring.factories b/spring-boot-actuator/src/main/resources/META-INF/spring.factories index 79f24c77ec..f5f1116003 100644 --- a/spring-boot-actuator/src/main/resources/META-INF/spring.factories +++ b/spring-boot-actuator/src/main/resources/META-INF/spring.factories @@ -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,\ diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AccessLevelTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AccessLevelTests.java new file mode 100644 index 0000000000..301ceaea36 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AccessLevelTests.java @@ -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(); + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AuthorizationExceptionMatcher.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AuthorizationExceptionMatcher.java new file mode 100644 index 0000000000..9ee80e6a78 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AuthorizationExceptionMatcher.java @@ -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( + "CloudFoundryAuthorizationException with " + reason + " reason") { + + @Override + public boolean matches(Object object) { + return ((object instanceof CloudFoundryAuthorizationException) + && ((CloudFoundryAuthorizationException) object) + .getReason() == reason); + } + + }; + } + +} + diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java new file mode 100644 index 0000000000..75d97fc62f --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryActuatorAutoConfigurationTests.java @@ -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); + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationExceptionTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationExceptionTests.java new file mode 100644 index 0000000000..2a13144682 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationExceptionTests.java @@ -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"); + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java new file mode 100644 index 0000000000..3078c73a6b --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryMvcWebEndpointIntegrationTests.java @@ -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 clientConsumer) { + BiConsumer 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 readAll() { + return Collections.singletonMap("All", true); + } + + @ReadOperation + public Map readPart(@Selector String part) { + return Collections.singletonMap("part", part); + } + + @WriteOperation + public void write(String foo, String bar) { + this.endpointDelegate.write(foo, bar); + } + + } + + @Endpoint(id = "env") + static class TestEnvEndpoint { + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + } + + @Endpoint(id = "info") + static class TestInfoEndpoint { + + @ReadOperation + public Map readAll() { + return Collections.singletonMap("All", true); + } + + } + + @Configuration + @Import(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); + + } + +} + diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptorTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptorTests.java new file mode 100644 index 0000000000..0502113d28 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptorTests.java @@ -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 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 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()); + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityServiceTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityServiceTests.java new file mode 100644 index 0000000000..a5cf377bd5 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityServiceTests.java @@ -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 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 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(); + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java new file mode 100644 index 0000000000..df585067b1 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/SkipSslVerificationHttpRequestFactoryTests.java @@ -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 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 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; + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenTests.java new file mode 100644 index 0000000000..50551a19d2 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenTests.java @@ -0,0 +1,140 @@ +/* + * Copyright 2012-2017 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.springframework.boot.actuate.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; + } + +} + diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenValidatorTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenValidatorTests.java new file mode 100644 index 0000000000..66bd395eb1 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenValidatorTests.java @@ -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 INVALID_KEYS = Collections + .singletonMap("invalid-key", INVALID_KEY); + + private static final Map VALID_KEYS = Collections + .singletonMap("valid-key", VALID_KEY); + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + this.tokenValidator = new 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(); + } + +} + diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/AbstractWebEndpointServletHandlerMapping.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/AbstractWebEndpointServletHandlerMapping.java new file mode 100644 index 0000000000..22b53e92ee --- /dev/null +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/AbstractWebEndpointServletHandlerMapping.java @@ -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> 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> 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> webEndpoints, + CorsConfiguration corsConfiguration) { + this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath; + this.webEndpoints = webEndpoints; + this.corsConfiguration = corsConfiguration; + setOrder(-100); + } + + public Collection> 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 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 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; + } + + } + + +} diff --git a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java index 8f412abf84..5119533344 100644 --- a/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java +++ b/spring-boot/src/main/java/org/springframework/boot/endpoint/web/mvc/WebEndpointServletHandlerMapping.java @@ -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> 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> webEndpoints, CorsConfiguration corsConfiguration) { - this.endpointPath = (endpointPath.startsWith("/") ? "" : "/") + endpointPath; - this.webEndpoints = webEndpoints; - this.corsConfiguration = corsConfiguration; + super(endpointPath, webEndpoints, corsConfiguration); setOrder(-100); } - public Collection> 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 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 interceptors) { - interceptors.add(new SkipPathExtensionContentNegotiation()); + protected Method getLinks() { + return this.links; } @ResponseBody private Map> 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; - } - - } - }