From 340f1d557452924eb5d617ef0bc00c0e58396a53 Mon Sep 17 00:00:00 2001 From: Madhura Bhave Date: Thu, 3 Nov 2016 11:08:07 -0700 Subject: [PATCH] Add security for Cloud Foundry actuators Add security to Cloud Foundry actuator endpoints. Security is enforced by a `HanderInterceptor` on `CloudFoundryEndpointHandlerMapping`. Each endpoint call expects an 'Authorization' header containing a bearer token. The token signature is checked against the UAA public keys then passed to the Cloud Controller to obtain an ultimate access level. The client may either have 'RESTRICTED' or FULL' access, with the latter only providing access to a limited set of endpoints. See gh-7108 --- .../actuate/cloudfoundry/AccessLevel.java | 61 +++++ ...CloudFoundryActuatorAutoConfiguration.java | 30 ++- .../CloudFoundryAuthorizationException.java | 90 +++++++ .../CloudFoundryEndpointHandlerMapping.java | 27 +- .../CloudFoundrySecurityInterceptor.java | 103 +++++++ .../CloudFoundrySecurityService.java | 142 ++++++++++ .../boot/actuate/cloudfoundry/Token.java | 121 +++++++++ .../actuate/cloudfoundry/TokenValidator.java | 135 ++++++++++ .../cloudfoundry/AccessLevelTests.java | 54 ++++ .../AuthorizationExceptionMatcher.java | 48 ++++ ...FoundryActuatorAutoConfigurationTests.java | 56 +++- ...oudFoundryAuthorizationExceptionTests.java | 92 +++++++ ...oudFoundryEndpointHandlerMappingTests.java | 19 +- .../CloudFoundrySecurityInterceptorTests.java | 196 ++++++++++++++ .../CloudFoundrySecurityServiceTests.java | 194 +++++++++++++ .../boot/actuate/cloudfoundry/TokenTests.java | 122 +++++++++ .../cloudfoundry/TokenValidatorTests.java | 255 ++++++++++++++++++ 17 files changed, 1709 insertions(+), 36 deletions(-) create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/AccessLevel.java create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationException.java create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptor.java create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityService.java create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/Token.java create mode 100644 spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/TokenValidator.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AccessLevelTests.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AuthorizationExceptionMatcher.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationExceptionTests.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptorTests.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityServiceTests.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenTests.java create mode 100644 spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenValidatorTests.java 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..15f1c3e598 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/AccessLevel.java @@ -0,0 +1,61 @@ +/* + * 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 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 endpoitn 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("cloudFoundryAccessLevel", this); + } + +} 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 index 6ca8b7f5cc..624fd1d414 100644 --- 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 @@ -28,9 +28,12 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.cloud.CloudPlatform; +import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.env.Environment; import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.servlet.HandlerInterceptor; /** * {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for @@ -48,15 +51,38 @@ public class CloudFoundryActuatorAutoConfiguration { @Bean public CloudFoundryEndpointHandlerMapping cloudFoundryEndpointHandlerMapping( - MvcEndpoints mvcEndpoints) { + MvcEndpoints mvcEndpoints, RestTemplateBuilder restTemplateBuilder, + Environment environment) { Set endpoints = new LinkedHashSet( mvcEndpoints.getEndpoints(NamedMvcEndpoint.class)); + HandlerInterceptor securityInterceptor = getSecurityInterceptor( + restTemplateBuilder, environment); + CorsConfiguration corsConfiguration = getCorsConfiguration(); CloudFoundryEndpointHandlerMapping mapping = new CloudFoundryEndpointHandlerMapping( - endpoints, getCorsConfiguration()); + endpoints, corsConfiguration, securityInterceptor); mapping.setPrefix("/cloudfoundryapplication"); return mapping; } + private HandlerInterceptor getSecurityInterceptor( + RestTemplateBuilder restTemplateBuilder, Environment environment) { + CloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService( + restTemplateBuilder, environment); + TokenValidator tokenValidator = new TokenValidator(cloudfoundrySecurityService); + HandlerInterceptor securityInterceptor = new CloudFoundrySecurityInterceptor( + tokenValidator, cloudfoundrySecurityService, + environment.getProperty("vcap.application.application_id")); + return securityInterceptor; + } + + private CloudFoundrySecurityService getCloudFoundrySecurityService( + RestTemplateBuilder restTemplateBuilder, Environment environment) { + String cloudControllerUrl = environment.getProperty("vcap.application.cf_api"); + return cloudControllerUrl == null ? null + : new CloudFoundrySecurityService(restTemplateBuilder, + cloudControllerUrl); + } + private CorsConfiguration getCorsConfiguration() { CorsConfiguration corsConfiguration = new CorsConfiguration(); corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL); 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..9127e2b5e5 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryAuthorizationException.java @@ -0,0 +1,90 @@ +/* + * 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.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_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/CloudFoundryEndpointHandlerMapping.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryEndpointHandlerMapping.java index efe524a864..3f10756e9c 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryEndpointHandlerMapping.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryEndpointHandlerMapping.java @@ -18,13 +18,11 @@ package org.springframework.boot.actuate.cloudfoundry; import java.util.ArrayList; import java.util.Arrays; -import java.util.Collection; import java.util.Iterator; import java.util.List; import java.util.Set; import javax.servlet.http.HttpServletRequest; -import javax.servlet.http.HttpServletResponse; import org.springframework.boot.actuate.endpoint.Endpoint; import org.springframework.boot.actuate.endpoint.mvc.AbstractEndpointHandlerMapping; @@ -35,7 +33,6 @@ import org.springframework.web.cors.CorsConfiguration; import org.springframework.web.servlet.HandlerExecutionChain; import org.springframework.web.servlet.HandlerInterceptor; import org.springframework.web.servlet.HandlerMapping; -import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; /** * {@link HandlerMapping} to map {@link Endpoint}s to Cloud Foundry specific URLs. @@ -45,13 +42,12 @@ import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; class CloudFoundryEndpointHandlerMapping extends AbstractEndpointHandlerMapping { - CloudFoundryEndpointHandlerMapping(Collection endpoints) { - super(endpoints); - } + private final HandlerInterceptor securityInterceptor; - CloudFoundryEndpointHandlerMapping(Set endpoints, - CorsConfiguration corsConfiguration) { + CloudFoundryEndpointHandlerMapping(Set endpoints, + CorsConfiguration corsConfiguration, HandlerInterceptor securityInterceptor) { super(endpoints, corsConfiguration); + this.securityInterceptor = securityInterceptor; } @Override @@ -90,24 +86,11 @@ class CloudFoundryEndpointHandlerMapping private HandlerInterceptor[] addSecurityInterceptor(HandlerInterceptor[] existing) { List interceptors = new ArrayList(); - interceptors.add(new SecurityInterceptor()); + interceptors.add(this.securityInterceptor); if (existing != null) { interceptors.addAll(Arrays.asList(existing)); } return interceptors.toArray(new HandlerInterceptor[interceptors.size()]); } - /** - * Security interceptor to check cloud foundry token. - */ - static class SecurityInterceptor extends HandlerInterceptorAdapter { - - @Override - public boolean preHandle(HttpServletRequest request, HttpServletResponse response, - Object handler) throws Exception { - // Currently open - return true; - } - - } } 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..dd76fa9c87 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptor.java @@ -0,0 +1,103 @@ +/* + * 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 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.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason; +import org.springframework.boot.actuate.endpoint.mvc.MvcEndpoint; +import org.springframework.util.StringUtils; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import org.springframework.web.servlet.handler.HandlerInterceptorAdapter; + +/** + * {@link HandlerInterceptor} to check the cloud foundry token. + * + * @author Madhura Bhave + */ +class CloudFoundrySecurityInterceptor extends HandlerInterceptorAdapter { + + protected final Log logger = LogFactory.getLog(getClass()); + + private final TokenValidator tokenValidator; + + private final CloudFoundrySecurityService cloudFoundrySecurityService; + + private final String applicationId; + + CloudFoundrySecurityInterceptor(TokenValidator tokenValidator, + CloudFoundrySecurityService cloudFoundrySecurityService, + String applicationId) { + this.tokenValidator = tokenValidator; + this.cloudFoundrySecurityService = cloudFoundrySecurityService; + this.applicationId = applicationId; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, + Object o) throws Exception { + try { + if (!StringUtils.hasText(this.applicationId)) { + throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, + "Application id is not available"); + } + if (this.cloudFoundrySecurityService == null) { + throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, + "Cloud controller URL is not available"); + } + HandlerMethod handlerMethod = (HandlerMethod) o; + MvcEndpoint mvcEndpoint = (MvcEndpoint) handlerMethod.getBean(); + check(request, mvcEndpoint); + } + catch (CloudFoundryAuthorizationException ex) { + this.logger.error(ex); + response.setStatus(ex.getStatusCode().value()); + return false; + } + return true; + } + + private void check(HttpServletRequest request, MvcEndpoint mvcEndpoint) + throws Exception { + Token token = getToken(request); + this.tokenValidator.validate(token); + AccessLevel accessLevel = this.cloudFoundrySecurityService + .getAccessLevel(token.toString(), this.applicationId); + if (!accessLevel.isAccessAllowed(mvcEndpoint.getPath())) { + throw new 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(Reason.MISSING_AUTHORIZATION, + "Authorization header is missing or invalid"); + } + return new Token(authorization.substring(bearerPrefix.length())); + } + +} 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..cb303c6e5d --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityService.java @@ -0,0 +1,142 @@ +/* + * 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.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +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) { + Assert.notNull(restTemplateBuilder, "RestTemplateBuilder must not be null"); + Assert.notNull(cloudControllerUrl, "CloudControllerUrl must not be null"); + 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 List 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 List extractTokenKeys(Map response) { + List tokenKeys = new ArrayList(); + List keys = (List) response.get("keys"); + for (Object key : keys) { + tokenKeys.add((String) ((Map) key).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/Token.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/Token.java new file mode 100644 index 0000000000..0a529dd581 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/Token.java @@ -0,0 +1,121 @@ +/* + * 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.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); + } + + @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..31a29e2056 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/cloudfoundry/TokenValidator.java @@ -0,0 +1,135 @@ +/* + * 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.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.List; +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 List tokenKeys; + + TokenValidator(CloudFoundrySecurityService cloudFoundrySecurityService) { + this.securityService = cloudFoundrySecurityService; + } + + public void validate(Token token) { + validateAlgorithm(token); + validateSignature(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 validateSignature(Token token) { + if (this.tokenKeys == null || !hasValidSignature(token)) { + this.tokenKeys = this.securityService.fetchTokenKeys(); + if (!hasValidSignature(token)) { + throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE, + "RSA Signature did not match content"); + } + } + } + + private boolean hasValidSignature(Token token) { + for (String key : this.tokenKeys) { + if (hasValidSignature(token, key)) { + 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/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..51397837ee --- /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..81992a0fb5 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/AuthorizationExceptionMatcher.java @@ -0,0 +1,48 @@ +/* + * 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 index 6de48f7682..04a0a46562 100644 --- 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 @@ -29,9 +29,11 @@ import org.springframework.boot.autoconfigure.PropertyPlaceholderAutoConfigurati import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; import org.springframework.boot.autoconfigure.security.SecurityAutoConfiguration; import org.springframework.boot.autoconfigure.web.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.web.WebClientAutoConfiguration; import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; import org.springframework.boot.test.util.EnvironmentTestUtils; import org.springframework.mock.web.MockServletContext; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.context.support.AnnotationConfigWebApplicationContext; import static org.assertj.core.api.Assertions.assertThat; @@ -46,7 +48,7 @@ public class CloudFoundryActuatorAutoConfigurationTests { private AnnotationConfigWebApplicationContext context; @Before - public void setUp() { + public void setup() { this.context = new AnnotationConfigWebApplicationContext(); this.context.setServletContext(new MockServletContext()); this.context.register(SecurityAutoConfiguration.class, @@ -57,6 +59,7 @@ public class CloudFoundryActuatorAutoConfigurationTests { EndpointAutoConfiguration.class, EndpointWebMvcAutoConfiguration.class, ManagementServerPropertiesAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class, + WebClientAutoConfiguration.class, EndpointWebMvcManagementContextConfiguration.class, CloudFoundryActuatorAutoConfiguration.class); } @@ -70,13 +73,58 @@ public class CloudFoundryActuatorAutoConfigurationTests { @Test public void cloudFoundryPlatformActive() throws Exception { + CloudFoundryEndpointHandlerMapping handlerMapping = x(); + assertThat(handlerMapping.getPrefix()).isEqualTo("/cloudfoundryapplication"); + } + + @Test + public void cloudFoundryPlatformActiveSetsApplicationId() throws Exception { + CloudFoundryEndpointHandlerMapping handlerMapping = x(); + 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 { + CloudFoundryEndpointHandlerMapping handlerMapping = x(); + Object interceptor = ReflectionTestUtils.getField(handlerMapping, + "securityInterceptor"); + Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor, + "cloudFoundrySecurityService"); + String cloudControllerUrl = (String) ReflectionTestUtils + .getField(interceptorSecurityService, "cloudControllerUrl"); + assertThat(cloudControllerUrl).isEqualTo("http://my-cloud-controller.com"); + } + + @Test + public void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() + throws Exception { EnvironmentTestUtils.addEnvironment(this.context, "VCAP_APPLICATION:---", - "management.cloudfoundry.enabled:true"); + "management.cloudfoundry.enabled:true", + "vcap.application.application_id:my-app-id"); this.context.refresh(); - CloudFoundryEndpointHandlerMapping handlerMapping = this.context.getBean( + CloudFoundryEndpointHandlerMapping handlerMapping1 = this.context.getBean( "cloudFoundryEndpointHandlerMapping", CloudFoundryEndpointHandlerMapping.class); - assertThat(handlerMapping.getPrefix()).isEqualTo("/cloudfoundryapplication"); + CloudFoundryEndpointHandlerMapping handlerMapping = handlerMapping1; + Object securityInterceptor = ReflectionTestUtils.getField(handlerMapping, + "securityInterceptor"); + Object interceptorSecurityService = ReflectionTestUtils + .getField(securityInterceptor, "cloudFoundrySecurityService"); + assertThat(interceptorSecurityService).isNull(); + } + + private CloudFoundryEndpointHandlerMapping x() { + EnvironmentTestUtils.addEnvironment(this.context, "VCAP_APPLICATION:---", + "management.cloudfoundry.enabled:true", + "vcap.application.application_id:my-app-id", + "vcap.application.cf_api:http://my-cloud-controller.com"); + this.context.refresh(); + return this.context.getBean("cloudFoundryEndpointHandlerMapping", + CloudFoundryEndpointHandlerMapping.class); } @Test 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/CloudFoundryEndpointHandlerMappingTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryEndpointHandlerMappingTests.java index da837d23d2..613a59b60b 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryEndpointHandlerMappingTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundryEndpointHandlerMappingTests.java @@ -16,10 +16,10 @@ package org.springframework.boot.actuate.cloudfoundry; -import java.util.Arrays; import java.util.Collections; import org.junit.Test; +import org.mockito.Mockito; import org.springframework.boot.actuate.endpoint.AbstractEndpoint; import org.springframework.boot.actuate.endpoint.mvc.AbstractEndpointHandlerMappingTests; @@ -40,18 +40,20 @@ import static org.assertj.core.api.Assertions.assertThat; * * @author Madhura Bhave */ -public class CloudFoundryEndpointHandlerMappingTests extends AbstractEndpointHandlerMappingTests { +public class CloudFoundryEndpointHandlerMappingTests + extends AbstractEndpointHandlerMappingTests { @Test public void getHandlerExecutionChainShouldHaveSecurityInterceptor() throws Exception { + CloudFoundrySecurityInterceptor securityInterceptor = Mockito + .mock(CloudFoundrySecurityInterceptor.class); TestMvcEndpoint endpoint = new TestMvcEndpoint(new TestEndpoint("a")); CloudFoundryEndpointHandlerMapping handlerMapping = new CloudFoundryEndpointHandlerMapping( - Arrays.asList(endpoint)); + Collections.singleton(endpoint), null, securityInterceptor); HandlerExecutionChain handlerExecutionChain = handlerMapping .getHandlerExecutionChain(endpoint, new MockHttpServletRequest()); HandlerInterceptor[] interceptors = handlerExecutionChain.getInterceptors(); - assertThat(interceptors).hasAtLeastOneElementOfType( - CloudFoundryEndpointHandlerMapping.SecurityInterceptor.class); + assertThat(interceptors).contains(securityInterceptor); } @Test @@ -60,14 +62,14 @@ public class CloudFoundryEndpointHandlerMappingTests extends AbstractEndpointHan TestMvcEndpoint testMvcEndpoint = new TestMvcEndpoint(new TestEndpoint("a")); testMvcEndpoint.setPath("something-else"); CloudFoundryEndpointHandlerMapping handlerMapping = new CloudFoundryEndpointHandlerMapping( - Arrays.asList(testMvcEndpoint)); + Collections.singleton(testMvcEndpoint), null, null); assertThat(handlerMapping.getPath(testMvcEndpoint)).isEqualTo("/a"); } @Test public void doesNotRegisterHalJsonMvcEndpoint() throws Exception { CloudFoundryEndpointHandlerMapping handlerMapping = new CloudFoundryEndpointHandlerMapping( - Collections.singleton(new TestHalJsonMvcEndpoint())); + Collections.singleton(new TestHalJsonMvcEndpoint()), null, null); assertThat(handlerMapping.getEndpoints()).hasSize(0); } @@ -75,7 +77,7 @@ public class CloudFoundryEndpointHandlerMappingTests extends AbstractEndpointHan public void registersCloudFoundryDiscoveryEndpoint() throws Exception { StaticApplicationContext context = new StaticApplicationContext(); CloudFoundryEndpointHandlerMapping handlerMapping = new CloudFoundryEndpointHandlerMapping( - Collections.emptyList()); + Collections.emptySet(), null, null); handlerMapping.setPrefix("/test"); handlerMapping.setApplicationContext(context); handlerMapping.afterPropertiesSet(); @@ -119,6 +121,7 @@ public class CloudFoundryEndpointHandlerMappingTests extends AbstractEndpointHan }); } + } } 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..994058a188 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityInterceptorTests.java @@ -0,0 +1,196 @@ +/* + * 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.boot.actuate.endpoint.AbstractEndpoint; +import org.springframework.boot.actuate.endpoint.mvc.EndpointMvcAdapter; +import org.springframework.http.HttpStatus; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.util.Base64Utils; +import org.springframework.web.method.HandlerMethod; + +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 TestMvcEndpoint endpoint; + + private HandlerMethod handlerMethod; + + private MockHttpServletRequest request; + + private MockHttpServletResponse response; + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, + this.securityService, "my-app-id"); + this.endpoint = new TestMvcEndpoint(new TestEndpoint("a")); + this.handlerMethod = new HandlerMethod(this.endpoint, "invoke"); + this.request = new MockHttpServletRequest(); + this.response = new MockHttpServletResponse(); + } + + @Test + public void preHandleWhenTokenIsMissingShouldReturnFalse() throws Exception { + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + assertThat(preHandle).isFalse(); + assertThat(this.response.getStatus()) + .isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus().value()); + } + + @Test + public void preHandleWhenTokenIsNotBearerShouldReturnFalse() throws Exception { + this.request.addHeader("Authorization", mockAccessToken()); + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + assertThat(preHandle).isFalse(); + assertThat(this.response.getStatus()) + .isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus().value()); + } + + @Test + public void preHandleWhenApplicationIdIsNullShouldReturnFalse() throws Exception { + this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, + this.securityService, null); + this.request.addHeader("Authorization", "bearer " + mockAccessToken()); + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + assertThat(preHandle).isFalse(); + assertThat(this.response.getStatus()) + .isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus().value()); + } + + @Test + public void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnFalse() + throws Exception { + this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null, + "my-app-id"); + this.request.addHeader("Authorization", "bearer " + mockAccessToken()); + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + assertThat(preHandle).isFalse(); + assertThat(this.response.getStatus()) + .isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus().value()); + } + + @Test + public void preHandleWhenAccessIsNotAllowedShouldReturnFalse() throws Exception { + this.endpoint = new TestMvcEndpoint(new TestEndpoint("env")); + this.handlerMethod = new HandlerMethod(this.endpoint, "invoke"); + String accessToken = mockAccessToken(); + this.request.addHeader("Authorization", "bearer " + accessToken); + BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id")) + .willReturn(AccessLevel.RESTRICTED); + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + assertThat(preHandle).isFalse(); + assertThat(this.response.getStatus()) + .isEqualTo(Reason.ACCESS_DENIED.getStatus().value()); + } + + @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); + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + ArgumentCaptor tokenArgumentCaptor = ArgumentCaptor.forClass(Token.class); + verify(this.tokenValidator).validate(tokenArgumentCaptor.capture()); + Token token = tokenArgumentCaptor.getValue(); + assertThat(token.toString()).isEqualTo(accessToken); + assertThat(preHandle).isTrue(); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.request.getAttribute("cloudFoundryAccessLevel")) + .isEqualTo(AccessLevel.FULL); + } + + @Test + public void preHandleSuccessfulWithRestrictedAccess() throws Exception { + this.endpoint = new TestMvcEndpoint(new TestEndpoint("info")); + this.handlerMethod = new HandlerMethod(this.endpoint, "invoke"); + String accessToken = mockAccessToken(); + this.request.addHeader("Authorization", "Bearer " + accessToken); + BDDMockito.given(this.securityService.getAccessLevel(accessToken, "my-app-id")) + .willReturn(AccessLevel.RESTRICTED); + boolean preHandle = this.interceptor.preHandle(this.request, this.response, + this.handlerMethod); + ArgumentCaptor tokenArgumentCaptor = ArgumentCaptor.forClass(Token.class); + verify(this.tokenValidator).validate(tokenArgumentCaptor.capture()); + Token token = tokenArgumentCaptor.getValue(); + assertThat(token.toString()).isEqualTo(accessToken); + assertThat(preHandle).isTrue(); + assertThat(this.response.getStatus()).isEqualTo(HttpStatus.OK.value()); + assertThat(this.request.getAttribute("cloudFoundryAccessLevel")) + .isEqualTo(AccessLevel.RESTRICTED); + } + + private String mockAccessToken() { + return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu" + + "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ." + + Base64Utils.encodeToString("signature".getBytes()); + } + + private static class TestEndpoint extends AbstractEndpoint { + + TestEndpoint(String id) { + super(id); + } + + @Override + public Object invoke() { + return null; + } + + } + + private static class TestMvcEndpoint extends EndpointMvcAdapter { + + TestMvcEndpoint(TestEndpoint delegate) { + super(delegate); + } + + } + +} 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..585b69a759 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/CloudFoundrySecurityServiceTests.java @@ -0,0 +1,194 @@ +/* + * 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.List; + +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.web.client.MockRestServiceServer; + +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); + this.server = mockServerCustomizer.getServer(); + } + + @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\" : [ {\"value\" : \"" + + tokenKeyValue.replace("\n", "\\n") + "\"} ]}"; + this.server.expect(requestTo(UAA_URL + "/token_keys")) + .andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON)); + List tokenKeys = this.securityService.fetchTokenKeys(); + this.server.verify(); + assertThat(tokenKeys).containsExactly(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)); + List 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/TokenTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenTests.java new file mode 100644 index 0000000000..06e15d54ff --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenTests.java @@ -0,0 +1,122 @@ +/* + * 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.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; +import static org.springframework.boot.actuate.cloudfoundry.AuthorizationExceptionMatcher.withReason; + +/** + * 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(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(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(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(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.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(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(withReason(Reason.INVALID_TOKEN)); + token.getIssuer(); + } + + @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(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..58eda0aa9b --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/cloudfoundry/TokenValidatorTests.java @@ -0,0 +1,255 @@ +/* + * 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.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.List; + +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 List INVALID_KEYS = Collections + .singletonList(INVALID_KEY); + + private static final List VALID_KEYS = Collections.singletonList(VALID_KEY); + + @Before + public void setup() throws Exception { + MockitoAnnotations.initMocks(this); + this.tokenValidator = new TokenValidator(this.securityService); + } + + @Test + public void validateTokenWhenSignatureValidationFailsTwiceShouldThrowException() + throws Exception { + ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS); + given(this.securityService.fetchTokenKeys()).willReturn(INVALID_KEYS); + String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\",\"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 validateTokenWhenSignatureValidationSucceedsInTheSecondAttempt() + 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\": \"key-id\",\"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\": \"key-id\",\"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 validateTokenWhenSignatureValidShouldNotFetchTokenKeys() + throws Exception { + ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", VALID_KEYS); + given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa"); + String header = "{ \"alg\": \"RS256\", \"kid\": \"key-id\",\"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 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\": \"key-id\", \"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\": \"key-id\", \"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\": \"key-id\", \"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(); + } + +}