23 changed files with 1965 additions and 43 deletions
@ -0,0 +1,67 @@
@@ -0,0 +1,67 @@
|
||||
/* |
||||
* Copyright 2012-2016 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.boot.actuate.cloudfoundry; |
||||
|
||||
import java.util.Arrays; |
||||
import java.util.List; |
||||
|
||||
import javax.servlet.http.HttpServletRequest; |
||||
|
||||
/** |
||||
* The specific access level granted to the cloud foundry user that's calling the |
||||
* endpoints. |
||||
* |
||||
* @author Madhura Bhave |
||||
*/ |
||||
enum AccessLevel { |
||||
|
||||
/** |
||||
* Restricted access to a limited set of endpoints. |
||||
*/ |
||||
RESTRICTED("", "/health", "/info"), |
||||
|
||||
/** |
||||
* Full access to all endpoints. |
||||
*/ |
||||
FULL; |
||||
|
||||
private static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel"; |
||||
|
||||
private final List<String> endpointPaths; |
||||
|
||||
AccessLevel(String... endpointPaths) { |
||||
this.endpointPaths = Arrays.asList(endpointPaths); |
||||
} |
||||
|
||||
/** |
||||
* Returns if the access level should allow access to the specified endpoint path. |
||||
* @param endpointPath the 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(REQUEST_ATTRIBUTE, this); |
||||
} |
||||
|
||||
public static AccessLevel get(HttpServletRequest request) { |
||||
return (AccessLevel) request.getAttribute(REQUEST_ATTRIBUTE); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,90 @@
@@ -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; |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,103 @@
@@ -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())); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,143 @@
@@ -0,0 +1,143 @@
|
||||
/* |
||||
* 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 |
||||
.requestFactory(SkipSslVerificationHttpRequestFactory.class).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<String> fetchTokenKeys() { |
||||
try { |
||||
return extractTokenKeys(this.restTemplate |
||||
.getForObject(getUaaUrl() + "/token_keys", Map.class)); |
||||
} |
||||
catch (HttpStatusCodeException e) { |
||||
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, |
||||
"UAA not reachable"); |
||||
} |
||||
} |
||||
|
||||
private List<String> extractTokenKeys(Map<?, ?> response) { |
||||
List<String> tokenKeys = new ArrayList<String>(); |
||||
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; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,93 @@
@@ -0,0 +1,93 @@
|
||||
/* |
||||
* Copyright 2012-2016 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.boot.actuate.cloudfoundry; |
||||
|
||||
import java.io.IOException; |
||||
import java.net.HttpURLConnection; |
||||
import java.security.SecureRandom; |
||||
import java.security.cert.X509Certificate; |
||||
|
||||
import javax.net.ssl.HostnameVerifier; |
||||
import javax.net.ssl.HttpsURLConnection; |
||||
import javax.net.ssl.SSLContext; |
||||
import javax.net.ssl.SSLSession; |
||||
import javax.net.ssl.SSLSocketFactory; |
||||
import javax.net.ssl.TrustManager; |
||||
import javax.net.ssl.X509TrustManager; |
||||
|
||||
import org.springframework.http.client.SimpleClientHttpRequestFactory; |
||||
|
||||
/** |
||||
* {@link SimpleClientHttpRequestFactory} that skips SSL certificate verification. |
||||
* |
||||
* @author Madhura Bhave |
||||
*/ |
||||
class SkipSslVerificationHttpRequestFactory extends SimpleClientHttpRequestFactory { |
||||
|
||||
@Override |
||||
protected void prepareConnection(HttpURLConnection connection, String httpMethod) |
||||
throws IOException { |
||||
if (connection instanceof HttpsURLConnection) { |
||||
prepareHttpsConnection((HttpsURLConnection) connection); |
||||
} |
||||
super.prepareConnection(connection, httpMethod); |
||||
} |
||||
|
||||
private void prepareHttpsConnection(HttpsURLConnection connection) { |
||||
connection.setHostnameVerifier(new SkipHostnameVerifier()); |
||||
try { |
||||
connection.setSSLSocketFactory(createSslSocketFactory()); |
||||
} |
||||
catch (Exception ex) { |
||||
// Ignore
|
||||
} |
||||
} |
||||
|
||||
private SSLSocketFactory createSslSocketFactory() throws Exception { |
||||
SSLContext context = SSLContext.getInstance("TLS"); |
||||
context.init(null, new TrustManager[] { new SkipX509TrustManager() }, |
||||
new SecureRandom()); |
||||
return context.getSocketFactory(); |
||||
} |
||||
|
||||
private class SkipHostnameVerifier implements HostnameVerifier { |
||||
|
||||
@Override |
||||
public boolean verify(String s, SSLSession sslSession) { |
||||
return true; |
||||
} |
||||
|
||||
} |
||||
|
||||
private static class SkipX509TrustManager implements X509TrustManager { |
||||
|
||||
@Override |
||||
public X509Certificate[] getAcceptedIssuers() { |
||||
return new X509Certificate[0]; |
||||
} |
||||
|
||||
@Override |
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) { |
||||
} |
||||
|
||||
@Override |
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) { |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,121 @@
@@ -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<String, Object> header; |
||||
|
||||
private final Map<String, Object> claims; |
||||
|
||||
Token(String encoded) { |
||||
this.encoded = encoded; |
||||
int firstPeriod = encoded.indexOf('.'); |
||||
int lastPeriod = encoded.lastIndexOf('.'); |
||||
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) { |
||||
throw new CloudFoundryAuthorizationException( |
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, |
||||
"JWT must have header, body and signature"); |
||||
} |
||||
this.header = parseJson(encoded.substring(0, firstPeriod)); |
||||
this.claims = parseJson(encoded.substring(firstPeriod + 1, lastPeriod)); |
||||
this.signature = encoded.substring(lastPeriod + 1); |
||||
if (!StringUtils.hasLength(this.signature)) { |
||||
throw new CloudFoundryAuthorizationException( |
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, |
||||
"Token must have non-empty crypto segment"); |
||||
} |
||||
} |
||||
|
||||
private Map<String, Object> parseJson(String base64) { |
||||
try { |
||||
byte[] bytes = Base64Utils.decodeFromUrlSafeString(base64); |
||||
return JsonParserFactory.getJsonParser().parseMap(new String(bytes, UTF_8)); |
||||
} |
||||
catch (RuntimeException ex) { |
||||
throw new CloudFoundryAuthorizationException( |
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, |
||||
"Token could not be parsed", ex); |
||||
} |
||||
} |
||||
|
||||
public byte[] getContent() { |
||||
return this.encoded.substring(0, this.encoded.lastIndexOf(".")).getBytes(); |
||||
} |
||||
|
||||
public byte[] getSignature() { |
||||
return Base64Utils.decodeFromUrlSafeString(this.signature); |
||||
} |
||||
|
||||
public String getSignatureAlgorithm() { |
||||
return getRequired(this.header, "alg", String.class); |
||||
} |
||||
|
||||
public String getIssuer() { |
||||
return getRequired(this.claims, "iss", String.class); |
||||
} |
||||
|
||||
public long getExpiry() { |
||||
return getRequired(this.claims, "exp", Integer.class).longValue(); |
||||
} |
||||
|
||||
@SuppressWarnings("unchecked") |
||||
public List<String> getScope() { |
||||
return getRequired(this.claims, "scope", List.class); |
||||
} |
||||
|
||||
@SuppressWarnings("unchecked") |
||||
private <T> T getRequired(Map<String, Object> map, String key, Class<T> type) { |
||||
Object value = map.get(key); |
||||
if (value == null) { |
||||
throw new CloudFoundryAuthorizationException( |
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, |
||||
"Unable to get value from key " + key); |
||||
} |
||||
if (!type.isInstance(value)) { |
||||
throw new CloudFoundryAuthorizationException( |
||||
CloudFoundryAuthorizationException.Reason.INVALID_TOKEN, |
||||
"Unexpected value type from key " + key + " value " + value); |
||||
} |
||||
return (T) value; |
||||
} |
||||
|
||||
@Override |
||||
public String toString() { |
||||
return this.encoded; |
||||
}; |
||||
|
||||
} |
||||
@ -0,0 +1,135 @@
@@ -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<String> 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"); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
/* |
||||
* Copyright 2012-2016 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.boot.actuate.cloudfoundry; |
||||
|
||||
import org.junit.Test; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
||||
/** |
||||
* Tests for {@link AccessLevel}. |
||||
* |
||||
* @author Madhura Bhave |
||||
*/ |
||||
public class AccessLevelTests { |
||||
|
||||
@Test |
||||
public void accessToHealthEndpointShouldNotBeRestricted() throws Exception { |
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("/health")).isTrue(); |
||||
assertThat(AccessLevel.FULL.isAccessAllowed("/health")).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
public void accessToInfoEndpointShouldNotBeRestricted() throws Exception { |
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("/info")).isTrue(); |
||||
assertThat(AccessLevel.FULL.isAccessAllowed("/info")).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
public void accessToDiscoveryEndpointShouldNotBeRestricted() throws Exception { |
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("")).isTrue(); |
||||
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue(); |
||||
} |
||||
|
||||
@Test |
||||
public void accessToAnyOtherEndpointShouldBeRestricted() throws Exception { |
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("env")).isFalse(); |
||||
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,48 @@
@@ -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<Object>( |
||||
"CloudFoundryAuthorizationException with " + reason + " reason") { |
||||
|
||||
@Override |
||||
public boolean matches(Object object) { |
||||
return ((object instanceof CloudFoundryAuthorizationException) |
||||
&& ((CloudFoundryAuthorizationException) object) |
||||
.getReason() == reason); |
||||
} |
||||
|
||||
}; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,92 @@
@@ -0,0 +1,92 @@
|
||||
/* |
||||
* Copyright 2012-2016 the original author or authors. |
||||
* |
||||
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||
* you may not use this file except in compliance with the License. |
||||
* You may obtain a copy of the License at |
||||
* |
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
* |
||||
* Unless required by applicable law or agreed to in writing, software |
||||
* distributed under the License is distributed on an "AS IS" BASIS, |
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||
* See the License for the specific language governing permissions and |
||||
* limitations under the License. |
||||
*/ |
||||
|
||||
package org.springframework.boot.actuate.cloudfoundry; |
||||
|
||||
import org.junit.Test; |
||||
|
||||
import org.springframework.boot.actuate.cloudfoundry.CloudFoundryAuthorizationException.Reason; |
||||
import org.springframework.http.HttpStatus; |
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat; |
||||
|
||||
/** |
||||
* Tests for {@link CloudFoundryAuthorizationException}. |
||||
* |
||||
* @author Madhura Bhave |
||||
*/ |
||||
public class CloudFoundryAuthorizationExceptionTests { |
||||
|
||||
@Test |
||||
public void statusCodeForInvalidTokenReasonShouldBe401() throws Exception { |
||||
assertThat(createException(Reason.INVALID_TOKEN).getStatusCode()) |
||||
.isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForInvalidIssuerReasonShouldBe401() throws Exception { |
||||
assertThat(createException(Reason.INVALID_ISSUER).getStatusCode()) |
||||
.isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForInvalidAudienceReasonShouldBe401() throws Exception { |
||||
assertThat(createException(Reason.INVALID_AUDIENCE).getStatusCode()) |
||||
.isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForInvalidSignatureReasonShouldBe401() throws Exception { |
||||
assertThat(createException(Reason.INVALID_SIGNATURE).getStatusCode()) |
||||
.isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForMissingAuthorizationReasonShouldBe401() throws Exception { |
||||
assertThat(createException(Reason.MISSING_AUTHORIZATION).getStatusCode()) |
||||
.isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForUnsupportedSignatureAlgorithmReasonShouldBe401() |
||||
throws Exception { |
||||
assertThat(createException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM) |
||||
.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForTokenExpiredReasonShouldBe401() throws Exception { |
||||
assertThat(createException(Reason.TOKEN_EXPIRED).getStatusCode()) |
||||
.isEqualTo(HttpStatus.UNAUTHORIZED); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForAccessDeniedReasonShouldBe403() throws Exception { |
||||
assertThat(createException(Reason.ACCESS_DENIED).getStatusCode()) |
||||
.isEqualTo(HttpStatus.FORBIDDEN); |
||||
} |
||||
|
||||
@Test |
||||
public void statusCodeForServiceUnavailableReasonShouldBe503() throws Exception { |
||||
assertThat(createException(Reason.SERVICE_UNAVAILABLE).getStatusCode()) |
||||
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE); |
||||
} |
||||
|
||||
private CloudFoundryAuthorizationException createException(Reason reason) { |
||||
return new CloudFoundryAuthorizationException(reason, "message"); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,196 @@
@@ -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<Token> 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<Token> 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<Object> { |
||||
|
||||
TestEndpoint(String id) { |
||||
super(id); |
||||
} |
||||
|
||||
@Override |
||||
public Object invoke() { |
||||
return null; |
||||
} |
||||
|
||||
} |
||||
|
||||
private static class TestMvcEndpoint extends EndpointMvcAdapter { |
||||
|
||||
TestMvcEndpoint(TestEndpoint delegate) { |
||||
super(delegate); |
||||
} |
||||
|
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,194 @@
@@ -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<String> 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<String> tokenKeys = this.securityService.fetchTokenKeys(); |
||||
this.server.verify(); |
||||
assertThat(tokenKeys).hasSize(0); |
||||
} |
||||
|
||||
@Test |
||||
public void fetchTokenKeysWhenUnsuccessfulShouldThrowException() throws Exception { |
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withSuccess( |
||||
"{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON)); |
||||
this.server.expect(requestTo(UAA_URL + "/token_keys")) |
||||
.andRespond(withServerError()); |
||||
this.thrown.expect( |
||||
AuthorizationExceptionMatcher.withReason(Reason.SERVICE_UNAVAILABLE)); |
||||
this.securityService.fetchTokenKeys(); |
||||
} |
||||
|
||||
@Test |
||||
public void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() throws Exception { |
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withSuccess( |
||||
"{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON)); |
||||
String uaaUrl = this.securityService.getUaaUrl(); |
||||
this.server.verify(); |
||||
assertThat(uaaUrl).isEqualTo(UAA_URL); |
||||
// Second call should not need to hit server
|
||||
uaaUrl = this.securityService.getUaaUrl(); |
||||
assertThat(uaaUrl).isEqualTo(UAA_URL); |
||||
} |
||||
|
||||
@Test |
||||
public void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException() |
||||
throws Exception { |
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")) |
||||
.andRespond(withServerError()); |
||||
this.thrown.expect( |
||||
AuthorizationExceptionMatcher.withReason(Reason.SERVICE_UNAVAILABLE)); |
||||
this.securityService.getUaaUrl(); |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
|
||||
/* |
||||
* 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.net.ssl.SSLHandshakeException; |
||||
|
||||
import org.hamcrest.Matcher; |
||||
import org.junit.Rule; |
||||
import org.junit.Test; |
||||
import org.junit.rules.ExpectedException; |
||||
|
||||
import org.springframework.boot.context.embedded.EmbeddedServletContainer; |
||||
import org.springframework.boot.context.embedded.ExampleServlet; |
||||
import org.springframework.boot.context.embedded.Ssl; |
||||
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory; |
||||
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(); |
||||
|
||||
@Test |
||||
public void restCallToSelfSignedServershouldNotThrowSslException() throws Exception { |
||||
String httpsUrl = getHttpsUrl(); |
||||
SkipSslVerificationHttpRequestFactory requestFactory = new SkipSslVerificationHttpRequestFactory(); |
||||
RestTemplate restTemplate = new RestTemplate(requestFactory); |
||||
ResponseEntity<String> responseEntity = restTemplate.getForEntity(httpsUrl, |
||||
String.class); |
||||
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK); |
||||
this.thrown.expect(ResourceAccessException.class); |
||||
this.thrown.expectCause(isSSLHandshakeException()); |
||||
RestTemplate otherRestTemplate = new RestTemplate(); |
||||
otherRestTemplate.getForEntity(httpsUrl, String.class); |
||||
} |
||||
|
||||
private Matcher<Throwable> isSSLHandshakeException() { |
||||
return instanceOf(SSLHandshakeException.class); |
||||
} |
||||
|
||||
private String getHttpsUrl() { |
||||
TomcatEmbeddedServletContainerFactory factory = new TomcatEmbeddedServletContainerFactory( |
||||
0); |
||||
factory.setSsl(getSsl("password", "classpath:test.jks")); |
||||
EmbeddedServletContainer container = factory.getEmbeddedServletContainer( |
||||
new ServletRegistrationBean(new ExampleServlet(), "/hello")); |
||||
container.start(); |
||||
return "https://localhost:" + container.getPort() + "/hello"; |
||||
} |
||||
|
||||
private Ssl getSsl(String keyPassword, String keyStore) { |
||||
Ssl ssl = new Ssl(); |
||||
ssl.setEnabled(true); |
||||
ssl.setKeyPassword(keyPassword); |
||||
ssl.setKeyStore(keyStore); |
||||
ssl.setKeyStorePassword("secret"); |
||||
return ssl; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,128 @@
@@ -0,0 +1,128 @@
|
||||
/* |
||||
* 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; |
||||
|
||||
/** |
||||
* 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.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 getExpiryWhenExpIsNullShouldThrowException() throws Exception { |
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}"; |
||||
String claims = "{\"iss\": \"http://localhost:8080/uaa/oauth/token\"" + "}"; |
||||
Token token = createToken(header, claims); |
||||
this.thrown |
||||
.expect(AuthorizationExceptionMatcher.withReason(Reason.INVALID_TOKEN)); |
||||
token.getExpiry(); |
||||
} |
||||
|
||||
private Token createToken(String header, String claims) { |
||||
Token token = new Token(Base64Utils.encodeToString(header.getBytes()) + "." |
||||
+ Base64Utils.encodeToString(claims.getBytes()) + "." |
||||
+ Base64Utils.encodeToString("signature".getBytes())); |
||||
return token; |
||||
} |
||||
|
||||
} |
||||
@ -0,0 +1,255 @@
@@ -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<String> INVALID_KEYS = Collections |
||||
.singletonList(INVALID_KEY); |
||||
|
||||
private static final List<String> 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(); |
||||
} |
||||
|
||||
} |
||||
Loading…
Reference in new issue