Create spring-boot-cloudfoundry module
This commit is contained in:
committed by
Phillip Webb
parent
1d2604602c
commit
f680582019
@@ -0,0 +1,61 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* The specific access level granted to the cloud foundry user that's calling the
|
||||
* endpoints.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public enum AccessLevel {
|
||||
|
||||
/**
|
||||
* Restricted access to a limited set of endpoints.
|
||||
*/
|
||||
RESTRICTED("", "health", "info"),
|
||||
|
||||
/**
|
||||
* Full access to all endpoints.
|
||||
*/
|
||||
FULL;
|
||||
|
||||
/**
|
||||
* The request attribute used to store the {@link AccessLevel}.
|
||||
*/
|
||||
public static final String REQUEST_ATTRIBUTE = "cloudFoundryAccessLevel";
|
||||
|
||||
private final List<String> ids;
|
||||
|
||||
AccessLevel(String... ids) {
|
||||
this.ids = Arrays.asList(ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns if the access level should allow access to the specified ID.
|
||||
* @param id the ID to check
|
||||
* @return {@code true} if access is allowed
|
||||
*/
|
||||
public boolean isAccessAllowed(String id) {
|
||||
return this.ids.isEmpty() || this.ids.contains(id);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Authorization exceptions thrown to limit access to the endpoints.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public class CloudFoundryAuthorizationException extends RuntimeException {
|
||||
|
||||
private final Reason reason;
|
||||
|
||||
public CloudFoundryAuthorizationException(Reason reason, String message) {
|
||||
this(reason, message, null);
|
||||
}
|
||||
|
||||
public CloudFoundryAuthorizationException(Reason reason, String message, Throwable cause) {
|
||||
super(message, cause);
|
||||
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.
|
||||
*/
|
||||
public enum Reason {
|
||||
|
||||
/**
|
||||
* Access Denied.
|
||||
*/
|
||||
ACCESS_DENIED(HttpStatus.FORBIDDEN),
|
||||
|
||||
/**
|
||||
* Invalid Audience.
|
||||
*/
|
||||
INVALID_AUDIENCE(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Invalid Issuer.
|
||||
*/
|
||||
INVALID_ISSUER(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Invalid Key ID.
|
||||
*/
|
||||
INVALID_KEY_ID(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Invalid Signature.
|
||||
*/
|
||||
INVALID_SIGNATURE(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Invalid Token.
|
||||
*/
|
||||
INVALID_TOKEN(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Missing Authorization.
|
||||
*/
|
||||
MISSING_AUTHORIZATION(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Token Expired.
|
||||
*/
|
||||
TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Unsupported Token Signing Algorithm.
|
||||
*/
|
||||
UNSUPPORTED_TOKEN_SIGNING_ALGORITHM(HttpStatus.UNAUTHORIZED),
|
||||
|
||||
/**
|
||||
* Service Unavailable.
|
||||
*/
|
||||
SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
Reason(HttpStatus status) {
|
||||
this.status = status;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.util.Set;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionMessage.Builder;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
|
||||
import org.springframework.boot.cloud.CloudPlatform;
|
||||
import org.springframework.core.env.Environment;
|
||||
|
||||
/**
|
||||
* {@link EndpointExposureOutcomeContributor} to expose {@link EndpointExposure#WEB web}
|
||||
* endpoints for Cloud Foundry.
|
||||
*
|
||||
* @author Phillip Webb
|
||||
*/
|
||||
class CloudFoundryEndpointExposureOutcomeContributor implements EndpointExposureOutcomeContributor {
|
||||
|
||||
private static final String PROPERTY = "management.endpoints.cloud-foundry.exposure";
|
||||
|
||||
private final IncludeExcludeEndpointFilter<?> filter;
|
||||
|
||||
CloudFoundryEndpointExposureOutcomeContributor(Environment environment) {
|
||||
this.filter = (!CloudPlatform.CLOUD_FOUNDRY.isActive(environment)) ? null
|
||||
: new IncludeExcludeEndpointFilter<>(ExposableEndpoint.class, environment, PROPERTY, "*");
|
||||
}
|
||||
|
||||
@Override
|
||||
public ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
|
||||
Builder message) {
|
||||
if (exposures.contains(EndpointExposure.WEB) && this.filter != null && this.filter.match(endpointId)) {
|
||||
return ConditionOutcome.match(message.because("marked as exposed by a '" + PROPERTY + "' property"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.EndpointFilter;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.DiscovererEndpointFilter;
|
||||
|
||||
/**
|
||||
* {@link EndpointFilter} for endpoints discovered by
|
||||
* {@link CloudFoundryWebEndpointDiscoverer}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryEndpointFilter extends DiscovererEndpointFilter {
|
||||
|
||||
protected CloudFoundryEndpointFilter() {
|
||||
super(CloudFoundryWebEndpointDiscoverer.class);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.aot.hint.MemberCategory;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointFilter;
|
||||
import org.springframework.boot.actuate.endpoint.OperationFilter;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.PathMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperation;
|
||||
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
|
||||
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer.CloudFoundryWebEndpointDiscovererRuntimeHints;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.ImportRuntimeHints;
|
||||
import org.springframework.core.annotation.MergedAnnotations;
|
||||
|
||||
/**
|
||||
* {@link WebEndpointDiscoverer} for Cloud Foundry that uses Cloud Foundry specific
|
||||
* extensions for the {@link HealthEndpoint}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@ImportRuntimeHints(CloudFoundryWebEndpointDiscovererRuntimeHints.class)
|
||||
public class CloudFoundryWebEndpointDiscoverer extends WebEndpointDiscoverer {
|
||||
|
||||
/**
|
||||
* Create a new {@link WebEndpointDiscoverer} instance.
|
||||
* @param applicationContext the source application context
|
||||
* @param parameterValueMapper the parameter value mapper
|
||||
* @param endpointMediaTypes the endpoint media types
|
||||
* @param endpointPathMappers the endpoint path mappers
|
||||
* @param invokerAdvisors invoker advisors to apply
|
||||
* @param endpointFilters endpoint filters to apply
|
||||
* @param operationFilters operation filters to apply
|
||||
*/
|
||||
public CloudFoundryWebEndpointDiscoverer(ApplicationContext applicationContext,
|
||||
ParameterValueMapper parameterValueMapper, EndpointMediaTypes endpointMediaTypes,
|
||||
List<PathMapper> endpointPathMappers, Collection<OperationInvokerAdvisor> invokerAdvisors,
|
||||
Collection<EndpointFilter<ExposableWebEndpoint>> endpointFilters,
|
||||
Collection<OperationFilter<WebOperation>> operationFilters) {
|
||||
super(applicationContext, parameterValueMapper, endpointMediaTypes, endpointPathMappers, null, invokerAdvisors,
|
||||
endpointFilters, operationFilters);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected boolean isExtensionTypeExposed(Class<?> extensionBeanType) {
|
||||
// Filter regular health endpoint extensions so a CF version can replace them
|
||||
return !isHealthEndpointExtension(extensionBeanType)
|
||||
|| isCloudFoundryHealthEndpointExtension(extensionBeanType);
|
||||
}
|
||||
|
||||
private boolean isHealthEndpointExtension(Class<?> extensionBeanType) {
|
||||
return MergedAnnotations.from(extensionBeanType)
|
||||
.get(EndpointWebExtension.class)
|
||||
.getValue("endpoint", Class.class)
|
||||
.map(HealthEndpoint.class::isAssignableFrom)
|
||||
.orElse(false);
|
||||
}
|
||||
|
||||
private boolean isCloudFoundryHealthEndpointExtension(Class<?> extensionBeanType) {
|
||||
return MergedAnnotations.from(extensionBeanType).isPresent(EndpointCloudFoundryExtension.class);
|
||||
}
|
||||
|
||||
static class CloudFoundryWebEndpointDiscovererRuntimeHints implements RuntimeHintsRegistrar {
|
||||
|
||||
@Override
|
||||
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
|
||||
hints.reflection()
|
||||
.registerType(CloudFoundryEndpointFilter.class, MemberCategory.INVOKE_DECLARED_CONSTRUCTORS);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
import java.lang.annotation.Target;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
|
||||
import org.springframework.core.annotation.AliasFor;
|
||||
|
||||
/**
|
||||
* Identifies a type as being a Cloud Foundry specific extension for an
|
||||
* {@link Endpoint @Endpoint}.
|
||||
*
|
||||
* @author Phillip Webb
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@Target(ElementType.TYPE)
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@Documented
|
||||
@EndpointExtension(filter = CloudFoundryEndpointFilter.class)
|
||||
public @interface EndpointCloudFoundryExtension {
|
||||
|
||||
/**
|
||||
* The class of the endpoint to provide a Cloud Foundry specific extension for.
|
||||
* @return the class of the endpoint to extend
|
||||
*/
|
||||
@AliasFor(annotation = EndpointExtension.class, attribute = "endpoint")
|
||||
Class<?> endpoint();
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
/**
|
||||
* Response from the Cloud Foundry security interceptors.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public class SecurityResponse {
|
||||
|
||||
private final HttpStatus status;
|
||||
|
||||
private final String message;
|
||||
|
||||
public SecurityResponse(HttpStatus status) {
|
||||
this(status, null);
|
||||
}
|
||||
|
||||
public SecurityResponse(HttpStatus status, String message) {
|
||||
this.status = status;
|
||||
this.message = message;
|
||||
}
|
||||
|
||||
public HttpStatus getStatus() {
|
||||
return this.status;
|
||||
}
|
||||
|
||||
public String getMessage() {
|
||||
return this.message;
|
||||
}
|
||||
|
||||
public static SecurityResponse success() {
|
||||
return new SecurityResponse(HttpStatus.OK);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Base64;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.json.JsonParserFactory;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
/**
|
||||
* The JSON web token provided with each request that originates from Cloud Foundry.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
public class Token {
|
||||
|
||||
private final String encoded;
|
||||
|
||||
private final String signature;
|
||||
|
||||
private final Map<String, Object> header;
|
||||
|
||||
private final Map<String, Object> claims;
|
||||
|
||||
public Token(String encoded) {
|
||||
this.encoded = encoded;
|
||||
int firstPeriod = encoded.indexOf('.');
|
||||
int lastPeriod = encoded.lastIndexOf('.');
|
||||
if (firstPeriod <= 0 || lastPeriod <= firstPeriod) {
|
||||
throw new 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(Reason.INVALID_TOKEN,
|
||||
"Token must have non-empty crypto segment");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> parseJson(String base64) {
|
||||
try {
|
||||
byte[] bytes = Base64.getUrlDecoder().decode(base64);
|
||||
return JsonParserFactory.getJsonParser().parseMap(new String(bytes, StandardCharsets.UTF_8));
|
||||
}
|
||||
catch (RuntimeException ex) {
|
||||
throw new 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 Base64.getUrlDecoder().decode(this.signature);
|
||||
}
|
||||
|
||||
public String getSignatureAlgorithm() {
|
||||
return getRequired(this.header, "alg", String.class);
|
||||
}
|
||||
|
||||
public String getIssuer() {
|
||||
return getRequired(this.claims, "iss", String.class);
|
||||
}
|
||||
|
||||
public long getExpiry() {
|
||||
return getRequired(this.claims, "exp", Integer.class).longValue();
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<String> getScope() {
|
||||
return getRequired(this.claims, "scope", List.class);
|
||||
}
|
||||
|
||||
public String getKeyId() {
|
||||
return getRequired(this.header, "kid", String.class);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private <T> T getRequired(Map<String, Object> map, String key, Class<T> type) {
|
||||
Object value = map.get(key);
|
||||
if (value == null) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, "Unable to get value from key " + key);
|
||||
}
|
||||
if (!type.isInstance(value)) {
|
||||
throw new 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,20 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Auto-configuration for actuator Cloud Foundry concerns.
|
||||
*/
|
||||
package org.springframework.boot.cloudfoundry.actuate.autoconfigure;
|
||||
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.endpoint.SecurityContext;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Selector;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
|
||||
import org.springframework.boot.actuate.health.HealthComponent;
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.EndpointCloudFoundryExtension;
|
||||
|
||||
/**
|
||||
* Reactive {@link EndpointExtension @EndpointExtension} for the {@link HealthEndpoint}
|
||||
* that always exposes full health details.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@EndpointCloudFoundryExtension(endpoint = HealthEndpoint.class)
|
||||
public class CloudFoundryReactiveHealthEndpointWebExtension {
|
||||
|
||||
private final ReactiveHealthEndpointWebExtension delegate;
|
||||
|
||||
public CloudFoundryReactiveHealthEndpointWebExtension(ReactiveHealthEndpointWebExtension delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
public Mono<WebEndpointResponse<? extends HealthComponent>> health(ApiVersion apiVersion) {
|
||||
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true);
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
public Mono<WebEndpointResponse<? extends HealthComponent>> health(ApiVersion apiVersion,
|
||||
@Selector(match = Match.ALL_REMAINING) String... path) {
|
||||
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true, path);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.cors.reactive.CorsUtils;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* Security interceptor to validate the cloud foundry token.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundrySecurityInterceptor {
|
||||
|
||||
private static final Log logger = LogFactory.getLog(CloudFoundrySecurityInterceptor.class);
|
||||
|
||||
private final ReactiveTokenValidator tokenValidator;
|
||||
|
||||
private final ReactiveCloudFoundrySecurityService cloudFoundrySecurityService;
|
||||
|
||||
private final String applicationId;
|
||||
|
||||
private static final Mono<SecurityResponse> SUCCESS = Mono.just(SecurityResponse.success());
|
||||
|
||||
CloudFoundrySecurityInterceptor(ReactiveTokenValidator tokenValidator,
|
||||
ReactiveCloudFoundrySecurityService cloudFoundrySecurityService, String applicationId) {
|
||||
this.tokenValidator = tokenValidator;
|
||||
this.cloudFoundrySecurityService = cloudFoundrySecurityService;
|
||||
this.applicationId = applicationId;
|
||||
}
|
||||
|
||||
Mono<SecurityResponse> preHandle(ServerWebExchange exchange, String id) {
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
if (CorsUtils.isPreFlightRequest(request)) {
|
||||
return SUCCESS;
|
||||
}
|
||||
if (!StringUtils.hasText(this.applicationId)) {
|
||||
return Mono.error(new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"Application id is not available"));
|
||||
}
|
||||
if (this.cloudFoundrySecurityService == null) {
|
||||
return Mono.error(new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"Cloud controller URL is not available"));
|
||||
}
|
||||
return check(exchange, id).then(SUCCESS).doOnError(this::logError).onErrorResume(this::getErrorResponse);
|
||||
}
|
||||
|
||||
private void logError(Throwable ex) {
|
||||
logger.error(ex.getMessage(), ex);
|
||||
}
|
||||
|
||||
private Mono<Void> check(ServerWebExchange exchange, String id) {
|
||||
try {
|
||||
Token token = getToken(exchange.getRequest());
|
||||
return this.tokenValidator.validate(token)
|
||||
.then(this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId))
|
||||
.filter((accessLevel) -> accessLevel.isAccessAllowed(id))
|
||||
.switchIfEmpty(
|
||||
Mono.error(new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied")))
|
||||
.doOnSuccess((accessLevel) -> exchange.getAttributes().put("cloudFoundryAccessLevel", accessLevel))
|
||||
.then();
|
||||
}
|
||||
catch (CloudFoundryAuthorizationException ex) {
|
||||
return Mono.error(ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Mono<SecurityResponse> getErrorResponse(Throwable throwable) {
|
||||
if (throwable instanceof CloudFoundryAuthorizationException cfException) {
|
||||
return Mono.just(new SecurityResponse(cfException.getStatusCode(),
|
||||
"{\"security_error\":\"" + cfException.getMessage() + "\"}"));
|
||||
}
|
||||
return Mono.just(new SecurityResponse(HttpStatus.INTERNAL_SERVER_ERROR, throwable.getMessage()));
|
||||
}
|
||||
|
||||
private Token getToken(ServerHttpRequest request) {
|
||||
String authorization = request.getHeaders().getFirst("Authorization");
|
||||
String bearerPrefix = "bearer ";
|
||||
if (authorization == null || !authorization.toLowerCase(Locale.ENGLISH).startsWith(bearerPrefix)) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.MISSING_AUTHORIZATION,
|
||||
"Authorization header is missing or invalid");
|
||||
}
|
||||
return new Token(authorization.substring(bearerPrefix.length()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.reactivestreams.Publisher;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
import org.springframework.aot.hint.annotation.Reflective;
|
||||
import org.springframework.aot.hint.annotation.ReflectiveRuntimeHintsRegistrar;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.Link;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperation;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.CloudFoundryWebFluxEndpointHandlerMapping.CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints;
|
||||
import org.springframework.boot.webflux.actuate.endpoint.web.AbstractWebFluxEndpointHandlerMapping;
|
||||
import org.springframework.context.annotation.ImportRuntimeHints;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.reactive.result.method.RequestMappingInfoHandlerMapping;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
/**
|
||||
* A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on
|
||||
* Cloud Foundry specific URLs over HTTP using Spring WebFlux.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @author Phillip Webb
|
||||
* @author Brian Clozel
|
||||
*/
|
||||
@ImportRuntimeHints(CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints.class)
|
||||
class CloudFoundryWebFluxEndpointHandlerMapping extends AbstractWebFluxEndpointHandlerMapping {
|
||||
|
||||
private final CloudFoundrySecurityInterceptor securityInterceptor;
|
||||
|
||||
private final EndpointLinksResolver linksResolver;
|
||||
|
||||
private final Collection<ExposableEndpoint<?>> allEndpoints;
|
||||
|
||||
CloudFoundryWebFluxEndpointHandlerMapping(EndpointMapping endpointMapping,
|
||||
Collection<ExposableWebEndpoint> endpoints, EndpointMediaTypes endpointMediaTypes,
|
||||
CorsConfiguration corsConfiguration, CloudFoundrySecurityInterceptor securityInterceptor,
|
||||
Collection<ExposableEndpoint<?>> allEndpoints) {
|
||||
super(endpointMapping, endpoints, endpointMediaTypes, corsConfiguration, true);
|
||||
this.linksResolver = new EndpointLinksResolver(allEndpoints);
|
||||
this.allEndpoints = allEndpoints;
|
||||
this.securityInterceptor = securityInterceptor;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ReactiveWebOperation wrapReactiveWebOperation(ExposableWebEndpoint endpoint, WebOperation operation,
|
||||
ReactiveWebOperation reactiveWebOperation) {
|
||||
return new SecureReactiveWebOperation(reactiveWebOperation, this.securityInterceptor, endpoint.getEndpointId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LinksHandler getLinksHandler() {
|
||||
return new CloudFoundryLinksHandler();
|
||||
}
|
||||
|
||||
Collection<ExposableEndpoint<?>> getAllEndpoints() {
|
||||
return this.allEndpoints;
|
||||
}
|
||||
|
||||
class CloudFoundryLinksHandler implements LinksHandler {
|
||||
|
||||
@Override
|
||||
@Reflective
|
||||
public Publisher<ResponseEntity<Object>> links(ServerWebExchange exchange) {
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
return CloudFoundryWebFluxEndpointHandlerMapping.this.securityInterceptor.preHandle(exchange, "")
|
||||
.map((securityResponse) -> {
|
||||
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
|
||||
return new ResponseEntity<>(securityResponse.getStatus());
|
||||
}
|
||||
AccessLevel accessLevel = exchange.getAttribute(AccessLevel.REQUEST_ATTRIBUTE);
|
||||
Map<String, Link> links = CloudFoundryWebFluxEndpointHandlerMapping.this.linksResolver
|
||||
.resolveLinks(request.getURI().toString());
|
||||
return new ResponseEntity<>(
|
||||
Collections.singletonMap("_links", getAccessibleLinks(accessLevel, links)), HttpStatus.OK);
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Link> getAccessibleLinks(AccessLevel accessLevel, Map<String, Link> links) {
|
||||
if (accessLevel == null) {
|
||||
return new LinkedHashMap<>();
|
||||
}
|
||||
return links.entrySet()
|
||||
.stream()
|
||||
.filter((entry) -> entry.getKey().equals("self") || accessLevel.isAccessAllowed(entry.getKey()))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Actuator root web endpoint";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ReactiveWebOperation} wrapper to add security.
|
||||
*/
|
||||
private static class SecureReactiveWebOperation implements ReactiveWebOperation {
|
||||
|
||||
private final ReactiveWebOperation delegate;
|
||||
|
||||
private final CloudFoundrySecurityInterceptor securityInterceptor;
|
||||
|
||||
private final EndpointId endpointId;
|
||||
|
||||
SecureReactiveWebOperation(ReactiveWebOperation delegate, CloudFoundrySecurityInterceptor securityInterceptor,
|
||||
EndpointId endpointId) {
|
||||
this.delegate = delegate;
|
||||
this.securityInterceptor = securityInterceptor;
|
||||
this.endpointId = endpointId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<ResponseEntity<Object>> handle(ServerWebExchange exchange, Map<String, String> body) {
|
||||
return this.securityInterceptor.preHandle(exchange, this.endpointId.toLowerCaseString())
|
||||
.flatMap((securityResponse) -> flatMapResponse(exchange, body, securityResponse));
|
||||
}
|
||||
|
||||
private Mono<ResponseEntity<Object>> flatMapResponse(ServerWebExchange exchange, Map<String, String> body,
|
||||
SecurityResponse securityResponse) {
|
||||
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
|
||||
return Mono.just(new ResponseEntity<>(securityResponse.getStatus()));
|
||||
}
|
||||
return this.delegate.handle(exchange, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints implements RuntimeHintsRegistrar {
|
||||
|
||||
private final ReflectiveRuntimeHintsRegistrar reflectiveRegistrar = new ReflectiveRuntimeHintsRegistrar();
|
||||
|
||||
private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();
|
||||
|
||||
@Override
|
||||
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
|
||||
this.reflectiveRegistrar.registerRuntimeHints(hints, CloudFoundryLinksHandler.class);
|
||||
this.bindingRegistrar.registerReflectionHints(hints.reflection(), Link.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.beans.factory.config.BeanPostProcessor;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.ReactiveHealthEndpointWebExtension;
|
||||
import org.springframework.boot.actuate.info.GitInfoContributor;
|
||||
import org.springframework.boot.actuate.info.InfoContributor;
|
||||
import org.springframework.boot.actuate.info.InfoEndpoint;
|
||||
import org.springframework.boot.actuate.info.InfoPropertiesInfoContributor;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.cloud.CloudPlatform;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryInfoEndpointWebExtension;
|
||||
import org.springframework.boot.info.GitProperties;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.web.server.MatcherSecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.WebFilterChainProxy;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher;
|
||||
import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers;
|
||||
import org.springframework.util.function.SingletonSupplier;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
|
||||
/**
|
||||
* {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for
|
||||
* Cloud Foundry to use in a reactive environment.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@AutoConfiguration(after = { HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class })
|
||||
@ConditionalOnBooleanProperty(name = "management.cloudfoundry.enabled", matchIfMissing = true)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE)
|
||||
@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY)
|
||||
public class ReactiveCloudFoundryActuatorAutoConfiguration {
|
||||
|
||||
private static final String BASE_PATH = "/cloudfoundryapplication";
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
@ConditionalOnBean({ HealthEndpoint.class, ReactiveHealthEndpointWebExtension.class })
|
||||
public CloudFoundryReactiveHealthEndpointWebExtension cloudFoundryReactiveHealthEndpointWebExtension(
|
||||
ReactiveHealthEndpointWebExtension reactiveHealthEndpointWebExtension) {
|
||||
return new CloudFoundryReactiveHealthEndpointWebExtension(reactiveHealthEndpointWebExtension);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
@ConditionalOnBean({ InfoEndpoint.class, GitProperties.class })
|
||||
public CloudFoundryInfoEndpointWebExtension cloudFoundryInfoEndpointWebExtension(GitProperties properties,
|
||||
ObjectProvider<InfoContributor> infoContributors) {
|
||||
List<InfoContributor> contributors = infoContributors.orderedStream()
|
||||
.map((infoContributor) -> (infoContributor instanceof GitInfoContributor)
|
||||
? new GitInfoContributor(properties, InfoPropertiesInfoContributor.Mode.FULL) : infoContributor)
|
||||
.toList();
|
||||
return new CloudFoundryInfoEndpointWebExtension(new InfoEndpoint(contributors));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@SuppressWarnings("removal")
|
||||
public CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebFluxEndpointHandlerMapping(
|
||||
ParameterValueMapper parameterMapper, EndpointMediaTypes endpointMediaTypes,
|
||||
WebClient.Builder webClientBuilder,
|
||||
org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier,
|
||||
ApplicationContext applicationContext) {
|
||||
CloudFoundryWebEndpointDiscoverer endpointDiscoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext,
|
||||
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(webClientBuilder,
|
||||
applicationContext.getEnvironment());
|
||||
Collection<ExposableWebEndpoint> webEndpoints = endpointDiscoverer.getEndpoints();
|
||||
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>();
|
||||
allEndpoints.addAll(webEndpoints);
|
||||
allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
|
||||
return new CloudFoundryWebFluxEndpointHandlerMapping(new EndpointMapping(BASE_PATH), webEndpoints,
|
||||
endpointMediaTypes, getCorsConfiguration(), securityInterceptor, allEndpoints);
|
||||
}
|
||||
|
||||
private CloudFoundrySecurityInterceptor getSecurityInterceptor(WebClient.Builder webClientBuilder,
|
||||
Environment environment) {
|
||||
ReactiveCloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService(
|
||||
webClientBuilder, environment);
|
||||
ReactiveTokenValidator tokenValidator = new ReactiveTokenValidator(cloudfoundrySecurityService);
|
||||
return new CloudFoundrySecurityInterceptor(tokenValidator, cloudfoundrySecurityService,
|
||||
environment.getProperty("vcap.application.application_id"));
|
||||
}
|
||||
|
||||
private ReactiveCloudFoundrySecurityService getCloudFoundrySecurityService(WebClient.Builder webClientBuilder,
|
||||
Environment environment) {
|
||||
String cloudControllerUrl = environment.getProperty("vcap.application.cf_api");
|
||||
boolean skipSslValidation = environment.getProperty("management.cloudfoundry.skip-ssl-validation",
|
||||
Boolean.class, false);
|
||||
return (cloudControllerUrl != null)
|
||||
? new ReactiveCloudFoundrySecurityService(webClientBuilder, cloudControllerUrl, skipSslValidation)
|
||||
: null;
|
||||
}
|
||||
|
||||
private CorsConfiguration getCorsConfiguration() {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL);
|
||||
corsConfiguration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
|
||||
corsConfiguration
|
||||
.setAllowedHeaders(Arrays.asList(HttpHeaders.AUTHORIZATION, "X-Cf-App-Instance", HttpHeaders.CONTENT_TYPE));
|
||||
return corsConfiguration;
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@ConditionalOnClass(MatcherSecurityWebFilterChain.class)
|
||||
static class IgnoredPathsSecurityConfiguration {
|
||||
|
||||
@Bean
|
||||
static WebFilterChainPostProcessor webFilterChainPostProcessor(
|
||||
ObjectProvider<CloudFoundryWebFluxEndpointHandlerMapping> handlerMapping) {
|
||||
return new WebFilterChainPostProcessor(handlerMapping);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class WebFilterChainPostProcessor implements BeanPostProcessor {
|
||||
|
||||
private final Supplier<PathMappedEndpoints> pathMappedEndpoints;
|
||||
|
||||
WebFilterChainPostProcessor(ObjectProvider<CloudFoundryWebFluxEndpointHandlerMapping> handlerMapping) {
|
||||
this.pathMappedEndpoints = SingletonSupplier
|
||||
.of(() -> new PathMappedEndpoints(BASE_PATH, () -> handlerMapping.getObject().getAllEndpoints()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
|
||||
if (bean instanceof WebFilterChainProxy webFilterChainProxy) {
|
||||
return postProcess(webFilterChainProxy);
|
||||
}
|
||||
return bean;
|
||||
}
|
||||
|
||||
private WebFilterChainProxy postProcess(WebFilterChainProxy existing) {
|
||||
List<String> paths = getPaths(this.pathMappedEndpoints.get());
|
||||
ServerWebExchangeMatcher cloudFoundryRequestMatcher = ServerWebExchangeMatchers
|
||||
.pathMatchers(paths.toArray(new String[] {}));
|
||||
WebFilter noOpFilter = (exchange, chain) -> chain.filter(exchange);
|
||||
MatcherSecurityWebFilterChain ignoredRequestFilterChain = new MatcherSecurityWebFilterChain(
|
||||
cloudFoundryRequestMatcher, Collections.singletonList(noOpFilter));
|
||||
MatcherSecurityWebFilterChain allRequestsFilterChain = new MatcherSecurityWebFilterChain(
|
||||
ServerWebExchangeMatchers.anyExchange(), Collections.singletonList(existing));
|
||||
return new WebFilterChainProxy(ignoredRequestFilterChain, allRequestsFilterChain);
|
||||
}
|
||||
|
||||
private static List<String> getPaths(PathMappedEndpoints pathMappedEndpoints) {
|
||||
List<String> paths = new ArrayList<>();
|
||||
pathMappedEndpoints.getAllPaths().forEach((path) -> paths.add(path + "/**"));
|
||||
paths.add(BASE_PATH);
|
||||
paths.add(BASE_PATH + "/");
|
||||
return paths;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import io.netty.handler.ssl.SslProvider;
|
||||
import io.netty.handler.ssl.util.InsecureTrustManagerFactory;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.netty.http.Http11SslContextSpec;
|
||||
import reactor.netty.http.client.HttpClient;
|
||||
import reactor.netty.tcp.SslProvider.GenericSslContextSpec;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.core.ParameterizedTypeReference;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.client.reactive.ReactorClientHttpConnector;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.reactive.function.client.WebClient.RequestHeadersSpec;
|
||||
import org.springframework.web.reactive.function.client.WebClientResponseException;
|
||||
|
||||
/**
|
||||
* Reactive Cloud Foundry security service to handle REST calls to the cloud controller
|
||||
* and UAA.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class ReactiveCloudFoundrySecurityService {
|
||||
|
||||
private static final ParameterizedTypeReference<Map<String, Object>> STRING_OBJECT_MAP = new ParameterizedTypeReference<>() {
|
||||
};
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
private final String cloudControllerUrl;
|
||||
|
||||
ReactiveCloudFoundrySecurityService(WebClient.Builder webClientBuilder, String cloudControllerUrl,
|
||||
boolean skipSslValidation) {
|
||||
Assert.notNull(webClientBuilder, "'webClientBuilder' must not be null");
|
||||
Assert.notNull(cloudControllerUrl, "'cloudControllerUrl' must not be null");
|
||||
if (skipSslValidation) {
|
||||
webClientBuilder.clientConnector(buildTrustAllSslConnector());
|
||||
}
|
||||
this.webClient = webClientBuilder.build();
|
||||
this.cloudControllerUrl = cloudControllerUrl;
|
||||
}
|
||||
|
||||
protected ReactorClientHttpConnector buildTrustAllSslConnector() {
|
||||
HttpClient client = HttpClient.create().secure((spec) -> spec.sslContext(createSslContextSpec()));
|
||||
return new ReactorClientHttpConnector(client);
|
||||
}
|
||||
|
||||
private GenericSslContextSpec<?> createSslContextSpec() {
|
||||
return Http11SslContextSpec.forClient()
|
||||
.configure((builder) -> builder.sslProvider(SslProvider.JDK)
|
||||
.trustManager(InsecureTrustManagerFactory.INSTANCE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Mono of the access level that should be granted to the given token.
|
||||
* @param token the token
|
||||
* @param applicationId the cloud foundry application ID
|
||||
* @return a Mono of the access level that should be granted
|
||||
* @throws CloudFoundryAuthorizationException if the token is not authorized
|
||||
*/
|
||||
Mono<AccessLevel> getAccessLevel(String token, String applicationId) throws CloudFoundryAuthorizationException {
|
||||
String uri = getPermissionsUri(applicationId);
|
||||
return this.webClient.get()
|
||||
.uri(uri)
|
||||
.header("Authorization", "bearer " + token)
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.map(this::getAccessLevel)
|
||||
.onErrorMap(this::mapError);
|
||||
}
|
||||
|
||||
private Throwable mapError(Throwable throwable) {
|
||||
if (throwable instanceof WebClientResponseException webClientResponseException) {
|
||||
HttpStatusCode statusCode = webClientResponseException.getStatusCode();
|
||||
if (statusCode.equals(HttpStatus.FORBIDDEN)) {
|
||||
return new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied");
|
||||
}
|
||||
if (statusCode.is4xxClientError()) {
|
||||
return new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN, "Invalid token", throwable);
|
||||
}
|
||||
}
|
||||
return new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, "Cloud controller not reachable");
|
||||
}
|
||||
|
||||
private AccessLevel getAccessLevel(Map<?, ?> body) {
|
||||
if (Boolean.TRUE.equals(body.get("read_sensitive_data"))) {
|
||||
return AccessLevel.FULL;
|
||||
}
|
||||
return AccessLevel.RESTRICTED;
|
||||
}
|
||||
|
||||
private String getPermissionsUri(String applicationId) {
|
||||
return this.cloudControllerUrl + "/v2/apps/" + applicationId + "/permissions";
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Mono of all token keys known by the UAA.
|
||||
* @return a Mono of token keys
|
||||
*/
|
||||
Mono<Map<String, String>> fetchTokenKeys() {
|
||||
return getUaaUrl().flatMap(this::fetchTokenKeys);
|
||||
}
|
||||
|
||||
private Mono<? extends Map<String, String>> fetchTokenKeys(String url) {
|
||||
RequestHeadersSpec<?> uri = this.webClient.get().uri(url + "/token_keys");
|
||||
return uri.retrieve()
|
||||
.bodyToMono(STRING_OBJECT_MAP)
|
||||
.map(this::extractTokenKeys)
|
||||
.onErrorMap(((ex) -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, ex.getMessage())));
|
||||
}
|
||||
|
||||
private Map<String, String> extractTokenKeys(Map<String, Object> response) {
|
||||
Map<String, String> tokenKeys = new HashMap<>();
|
||||
for (Object key : (List<?>) response.get("keys")) {
|
||||
Map<?, ?> tokenKey = (Map<?, ?>) key;
|
||||
tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value"));
|
||||
}
|
||||
return tokenKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return a Mono of URL of the UAA.
|
||||
* @return the UAA url Mono
|
||||
*/
|
||||
Mono<String> getUaaUrl() {
|
||||
return this.webClient.get()
|
||||
.uri(this.cloudControllerUrl + "/info")
|
||||
.retrieve()
|
||||
.bodyToMono(Map.class)
|
||||
.map((response) -> (String) response.get("token_endpoint"))
|
||||
.cache()
|
||||
.onErrorMap((ex) -> new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"Unable to fetch token keys from UAA."));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.KeyFactory;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.PublicKey;
|
||||
import java.security.Signature;
|
||||
import java.security.spec.InvalidKeySpecException;
|
||||
import java.security.spec.X509EncodedKeySpec;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
|
||||
|
||||
/**
|
||||
* Validator used to ensure that a signed {@link Token} has not been tampered with.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class ReactiveTokenValidator {
|
||||
|
||||
private final ReactiveCloudFoundrySecurityService securityService;
|
||||
|
||||
private volatile Map<String, String> cachedTokenKeys = Collections.emptyMap();
|
||||
|
||||
ReactiveTokenValidator(ReactiveCloudFoundrySecurityService securityService) {
|
||||
this.securityService = securityService;
|
||||
}
|
||||
|
||||
Mono<Void> validate(Token token) {
|
||||
return validateAlgorithm(token).then(validateKeyIdAndSignature(token))
|
||||
.then(validateExpiry(token))
|
||||
.then(validateIssuer(token))
|
||||
.then(validateAudience(token));
|
||||
}
|
||||
|
||||
private Mono<Void> validateAlgorithm(Token token) {
|
||||
String algorithm = token.getSignatureAlgorithm();
|
||||
if (algorithm == null) {
|
||||
return Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
|
||||
"Signing algorithm cannot be null"));
|
||||
}
|
||||
if (!algorithm.equals("RS256")) {
|
||||
return Mono.error(new CloudFoundryAuthorizationException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM,
|
||||
"Signing algorithm " + algorithm + " not supported"));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private Mono<Void> validateKeyIdAndSignature(Token token) {
|
||||
return getTokenKey(token).filter((tokenKey) -> hasValidSignature(token, tokenKey))
|
||||
.switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
|
||||
"RSA Signature did not match content")))
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<String> getTokenKey(Token token) {
|
||||
String keyId = token.getKeyId();
|
||||
String cached = this.cachedTokenKeys.get(keyId);
|
||||
if (cached != null) {
|
||||
return Mono.just(cached);
|
||||
}
|
||||
return this.securityService.fetchTokenKeys()
|
||||
.doOnSuccess(this::cacheTokenKeys)
|
||||
.filter((tokenKeys) -> tokenKeys.containsKey(keyId))
|
||||
.map((tokenKeys) -> tokenKeys.get(keyId))
|
||||
.switchIfEmpty(Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_KEY_ID,
|
||||
"Key Id present in token header does not match")));
|
||||
}
|
||||
|
||||
private void cacheTokenKeys(Map<String, String> tokenKeys) {
|
||||
this.cachedTokenKeys = Map.copyOf(tokenKeys);
|
||||
}
|
||||
|
||||
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 = Base64.getDecoder().decode(key);
|
||||
X509EncodedKeySpec keySpec = new X509EncodedKeySpec(bytes);
|
||||
return KeyFactory.getInstance("RSA").generatePublic(keySpec);
|
||||
}
|
||||
|
||||
private Mono<Void> validateExpiry(Token token) {
|
||||
long currentTime = TimeUnit.MILLISECONDS.toSeconds(System.currentTimeMillis());
|
||||
if (currentTime > token.getExpiry()) {
|
||||
return Mono.error(new CloudFoundryAuthorizationException(Reason.TOKEN_EXPIRED, "Token expired"));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
private Mono<Void> validateIssuer(Token token) {
|
||||
return this.securityService.getUaaUrl()
|
||||
.map((uaaUrl) -> String.format("%s/oauth/token", uaaUrl))
|
||||
.filter((issuerUri) -> issuerUri.equals(token.getIssuer()))
|
||||
.switchIfEmpty(Mono
|
||||
.error(new CloudFoundryAuthorizationException(Reason.INVALID_ISSUER, "Token issuer does not match")))
|
||||
.then();
|
||||
}
|
||||
|
||||
private Mono<Void> validateAudience(Token token) {
|
||||
if (!token.getScope().contains("actuator.read")) {
|
||||
return Mono.error(new CloudFoundryAuthorizationException(Reason.INVALID_AUDIENCE,
|
||||
"Token does not have audience actuator"));
|
||||
}
|
||||
return Mono.empty();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Auto-configuration for actuator Cloud Foundry concerns using WebFlux.
|
||||
*/
|
||||
package org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints;
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
|
||||
import org.springframework.boot.actuate.info.GitInfoContributor;
|
||||
import org.springframework.boot.actuate.info.InfoContributor;
|
||||
import org.springframework.boot.actuate.info.InfoEndpoint;
|
||||
import org.springframework.boot.actuate.info.InfoPropertiesInfoContributor;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnBooleanProperty;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnCloudPlatform;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
|
||||
import org.springframework.boot.cloud.CloudPlatform;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer;
|
||||
import org.springframework.boot.info.GitProperties;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.core.env.Environment;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.security.config.annotation.web.WebSecurityConfigurer;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.builders.WebSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.OrRequestMatcher;
|
||||
import org.springframework.security.web.util.matcher.RequestMatcher;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.DispatcherServlet;
|
||||
|
||||
/**
|
||||
* {@link EnableAutoConfiguration Auto-configuration} to expose actuator endpoints for
|
||||
* Cloud Foundry to use.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@AutoConfiguration(after = { HealthEndpointAutoConfiguration.class, InfoEndpointAutoConfiguration.class },
|
||||
afterName = "org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration")
|
||||
@ConditionalOnBooleanProperty(name = "management.cloudfoundry.enabled", matchIfMissing = true)
|
||||
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
|
||||
@ConditionalOnClass(DispatcherServlet.class)
|
||||
@ConditionalOnBean(DispatcherServlet.class)
|
||||
@ConditionalOnCloudPlatform(CloudPlatform.CLOUD_FOUNDRY)
|
||||
public class CloudFoundryActuatorAutoConfiguration {
|
||||
|
||||
private static final String BASE_PATH = "/cloudfoundryapplication";
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
@ConditionalOnBean({ HealthEndpoint.class, HealthEndpointWebExtension.class })
|
||||
public CloudFoundryHealthEndpointWebExtension cloudFoundryHealthEndpointWebExtension(
|
||||
HealthEndpointWebExtension healthEndpointWebExtension) {
|
||||
return new CloudFoundryHealthEndpointWebExtension(healthEndpointWebExtension);
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
@ConditionalOnBean({ InfoEndpoint.class, GitProperties.class })
|
||||
public CloudFoundryInfoEndpointWebExtension cloudFoundryInfoEndpointWebExtension(GitProperties properties,
|
||||
ObjectProvider<InfoContributor> infoContributors) {
|
||||
List<InfoContributor> contributors = infoContributors.orderedStream()
|
||||
.map((infoContributor) -> (infoContributor instanceof GitInfoContributor)
|
||||
? new GitInfoContributor(properties, InfoPropertiesInfoContributor.Mode.FULL) : infoContributor)
|
||||
.toList();
|
||||
return new CloudFoundryInfoEndpointWebExtension(new InfoEndpoint(contributors));
|
||||
}
|
||||
|
||||
@Bean
|
||||
@SuppressWarnings("removal")
|
||||
public CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
|
||||
ParameterValueMapper parameterMapper, EndpointMediaTypes endpointMediaTypes,
|
||||
RestTemplateBuilder restTemplateBuilder,
|
||||
org.springframework.boot.actuate.endpoint.web.annotation.ServletEndpointsSupplier servletEndpointsSupplier,
|
||||
org.springframework.boot.actuate.endpoint.web.annotation.ControllerEndpointsSupplier controllerEndpointsSupplier,
|
||||
ApplicationContext applicationContext) {
|
||||
CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(applicationContext,
|
||||
parameterMapper, endpointMediaTypes, null, Collections.emptyList(), Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
CloudFoundrySecurityInterceptor securityInterceptor = getSecurityInterceptor(restTemplateBuilder,
|
||||
applicationContext.getEnvironment());
|
||||
Collection<ExposableWebEndpoint> webEndpoints = discoverer.getEndpoints();
|
||||
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>();
|
||||
allEndpoints.addAll(webEndpoints);
|
||||
allEndpoints.addAll(servletEndpointsSupplier.getEndpoints());
|
||||
allEndpoints.addAll(controllerEndpointsSupplier.getEndpoints());
|
||||
return new CloudFoundryWebEndpointServletHandlerMapping(new EndpointMapping(BASE_PATH), webEndpoints,
|
||||
endpointMediaTypes, getCorsConfiguration(), securityInterceptor, allEndpoints);
|
||||
}
|
||||
|
||||
private CloudFoundrySecurityInterceptor getSecurityInterceptor(RestTemplateBuilder restTemplateBuilder,
|
||||
Environment environment) {
|
||||
CloudFoundrySecurityService cloudfoundrySecurityService = getCloudFoundrySecurityService(restTemplateBuilder,
|
||||
environment);
|
||||
TokenValidator tokenValidator = new TokenValidator(cloudfoundrySecurityService);
|
||||
return new CloudFoundrySecurityInterceptor(tokenValidator, cloudfoundrySecurityService,
|
||||
environment.getProperty("vcap.application.application_id"));
|
||||
}
|
||||
|
||||
private CloudFoundrySecurityService getCloudFoundrySecurityService(RestTemplateBuilder restTemplateBuilder,
|
||||
Environment environment) {
|
||||
String cloudControllerUrl = environment.getProperty("vcap.application.cf_api");
|
||||
boolean skipSslValidation = environment.getProperty("management.cloudfoundry.skip-ssl-validation",
|
||||
Boolean.class, false);
|
||||
return (cloudControllerUrl != null)
|
||||
? new CloudFoundrySecurityService(restTemplateBuilder, cloudControllerUrl, skipSslValidation) : null;
|
||||
}
|
||||
|
||||
private CorsConfiguration getCorsConfiguration() {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.addAllowedOrigin(CorsConfiguration.ALL);
|
||||
corsConfiguration.setAllowedMethods(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
|
||||
corsConfiguration
|
||||
.setAllowedHeaders(Arrays.asList(HttpHeaders.AUTHORIZATION, "X-Cf-App-Instance", HttpHeaders.CONTENT_TYPE));
|
||||
return corsConfiguration;
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link WebSecurityConfigurer} to tell Spring Security to permit cloudfoundry
|
||||
* specific paths. The Cloud foundry endpoints are protected by their own security
|
||||
* interceptor.
|
||||
*/
|
||||
@ConditionalOnClass({ WebSecurityCustomizer.class, WebSecurity.class })
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
public static class IgnoredCloudFoundryPathsWebSecurityConfiguration {
|
||||
|
||||
private static final int FILTER_CHAIN_ORDER = -1;
|
||||
|
||||
@Bean
|
||||
@Order(FILTER_CHAIN_ORDER)
|
||||
SecurityFilterChain cloudFoundrySecurityFilterChain(HttpSecurity http,
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping) throws Exception {
|
||||
RequestMatcher cloudFoundryRequest = getRequestMatcher(handlerMapping);
|
||||
http.csrf((csrf) -> csrf.ignoringRequestMatchers(cloudFoundryRequest));
|
||||
http.securityMatchers((matches) -> matches.requestMatchers(cloudFoundryRequest))
|
||||
.authorizeHttpRequests((authorize) -> authorize.anyRequest().permitAll());
|
||||
return http.build();
|
||||
}
|
||||
|
||||
private RequestMatcher getRequestMatcher(CloudFoundryWebEndpointServletHandlerMapping handlerMapping) {
|
||||
PathMappedEndpoints endpoints = new PathMappedEndpoints(BASE_PATH, handlerMapping::getAllEndpoints);
|
||||
List<RequestMatcher> matchers = new ArrayList<>();
|
||||
endpoints.getAllPaths().forEach((path) -> matchers.add(pathMatcher(path + "/**")));
|
||||
matchers.add(pathMatcher(BASE_PATH));
|
||||
matchers.add(pathMatcher(BASE_PATH + "/"));
|
||||
return new OrRequestMatcher(matchers);
|
||||
}
|
||||
|
||||
private PathPatternRequestMatcher pathMatcher(String path) {
|
||||
return PathPatternRequestMatcher.withDefaults().matcher(path);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.endpoint.SecurityContext;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Selector;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Selector.Match;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
|
||||
import org.springframework.boot.actuate.health.HealthComponent;
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.HealthEndpointWebExtension;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.EndpointCloudFoundryExtension;
|
||||
|
||||
/**
|
||||
* {@link EndpointExtension @EndpointExtension} for the {@link HealthEndpoint} that always
|
||||
* exposes full health details.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@EndpointCloudFoundryExtension(endpoint = HealthEndpoint.class)
|
||||
public class CloudFoundryHealthEndpointWebExtension {
|
||||
|
||||
private final HealthEndpointWebExtension delegate;
|
||||
|
||||
public CloudFoundryHealthEndpointWebExtension(HealthEndpointWebExtension delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
public WebEndpointResponse<HealthComponent> health(ApiVersion apiVersion) {
|
||||
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true);
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
public WebEndpointResponse<HealthComponent> health(ApiVersion apiVersion,
|
||||
@Selector(match = Match.ALL_REMAINING) String... path) {
|
||||
return this.delegate.health(apiVersion, null, SecurityContext.NONE, true, path);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.info.InfoEndpoint;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.EndpointCloudFoundryExtension;
|
||||
|
||||
/**
|
||||
* {@link EndpointExtension @EndpointExtension} for the {@link InfoEndpoint} that always
|
||||
* exposes full git details.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @since 4.0.0
|
||||
*/
|
||||
@EndpointCloudFoundryExtension(endpoint = InfoEndpoint.class)
|
||||
public class CloudFoundryInfoEndpointWebExtension {
|
||||
|
||||
private final InfoEndpoint delegate;
|
||||
|
||||
public CloudFoundryInfoEndpointWebExtension(InfoEndpoint delegate) {
|
||||
this.delegate = delegate;
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
public Map<String, Object> info() {
|
||||
return this.delegate.info();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.cors.CorsUtils;
|
||||
|
||||
/**
|
||||
* Security interceptor to validate the cloud foundry token.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundrySecurityInterceptor {
|
||||
|
||||
private static final Log logger = LogFactory.getLog(CloudFoundrySecurityInterceptor.class);
|
||||
|
||||
private final TokenValidator tokenValidator;
|
||||
|
||||
private final CloudFoundrySecurityService cloudFoundrySecurityService;
|
||||
|
||||
private final String applicationId;
|
||||
|
||||
private static final SecurityResponse SUCCESS = SecurityResponse.success();
|
||||
|
||||
CloudFoundrySecurityInterceptor(TokenValidator tokenValidator,
|
||||
CloudFoundrySecurityService cloudFoundrySecurityService, String applicationId) {
|
||||
this.tokenValidator = tokenValidator;
|
||||
this.cloudFoundrySecurityService = cloudFoundrySecurityService;
|
||||
this.applicationId = applicationId;
|
||||
}
|
||||
|
||||
SecurityResponse preHandle(HttpServletRequest request, EndpointId endpointId) {
|
||||
if (CorsUtils.isPreFlightRequest(request)) {
|
||||
return SecurityResponse.success();
|
||||
}
|
||||
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");
|
||||
}
|
||||
if (HttpMethod.OPTIONS.matches(request.getMethod())) {
|
||||
return SUCCESS;
|
||||
}
|
||||
check(request, endpointId);
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.error(ex);
|
||||
if (ex instanceof CloudFoundryAuthorizationException cfException) {
|
||||
return new SecurityResponse(cfException.getStatusCode(),
|
||||
"{\"security_error\":\"" + cfException.getMessage() + "\"}");
|
||||
}
|
||||
return new SecurityResponse(HttpStatus.INTERNAL_SERVER_ERROR, ex.getMessage());
|
||||
}
|
||||
return SecurityResponse.success();
|
||||
}
|
||||
|
||||
private void check(HttpServletRequest request, EndpointId endpointId) {
|
||||
Token token = getToken(request);
|
||||
this.tokenValidator.validate(token);
|
||||
AccessLevel accessLevel = this.cloudFoundrySecurityService.getAccessLevel(token.toString(), this.applicationId);
|
||||
if (!accessLevel.isAccessAllowed((endpointId != null) ? endpointId.toLowerCaseString() : "")) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.ACCESS_DENIED, "Access denied");
|
||||
}
|
||||
request.setAttribute(AccessLevel.REQUEST_ATTRIBUTE, accessLevel);
|
||||
}
|
||||
|
||||
private Token getToken(HttpServletRequest request) {
|
||||
String authorization = request.getHeader("Authorization");
|
||||
String bearerPrefix = "bearer ";
|
||||
if (authorization == null || !authorization.toLowerCase(Locale.ENGLISH).startsWith(bearerPrefix)) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.MISSING_AUTHORIZATION,
|
||||
"Authorization header is missing or invalid");
|
||||
}
|
||||
return new Token(authorization.substring(bearerPrefix.length()));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URISyntaxException;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.RequestEntity;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.web.client.HttpClientErrorException;
|
||||
import org.springframework.web.client.HttpServerErrorException;
|
||||
import org.springframework.web.client.HttpStatusCodeException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
/**
|
||||
* Cloud Foundry security service to handle REST calls to the cloud controller and UAA.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundrySecurityService {
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
|
||||
private final String cloudControllerUrl;
|
||||
|
||||
private String uaaUrl;
|
||||
|
||||
CloudFoundrySecurityService(RestTemplateBuilder restTemplateBuilder, String cloudControllerUrl,
|
||||
boolean skipSslValidation) {
|
||||
Assert.notNull(restTemplateBuilder, "'restTemplateBuilder' must not be null");
|
||||
Assert.notNull(cloudControllerUrl, "'cloudControllerUrl' must not be null");
|
||||
if (skipSslValidation) {
|
||||
restTemplateBuilder = restTemplateBuilder.requestFactory(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
this.restTemplate = restTemplateBuilder.build();
|
||||
this.cloudControllerUrl = cloudControllerUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the access level that should be granted to the given token.
|
||||
* @param token the token
|
||||
* @param applicationId the cloud foundry application ID
|
||||
* @return the access level that should be granted
|
||||
* @throws CloudFoundryAuthorizationException if the token is not authorized
|
||||
*/
|
||||
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 map of token keys
|
||||
*/
|
||||
Map<String, String> fetchTokenKeys() {
|
||||
try {
|
||||
return extractTokenKeys(this.restTemplate.getForObject(getUaaUrl() + "/token_keys", Map.class));
|
||||
}
|
||||
catch (HttpStatusCodeException ex) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE, "UAA not reachable");
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, String> extractTokenKeys(Map<?, ?> response) {
|
||||
Map<String, String> tokenKeys = new HashMap<>();
|
||||
for (Object key : (List<?>) response.get("keys")) {
|
||||
Map<?, ?> tokenKey = (Map<?, ?>) key;
|
||||
tokenKeys.put((String) tokenKey.get("kid"), (String) tokenKey.get("value"));
|
||||
}
|
||||
return tokenKeys;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the URL of the UAA.
|
||||
* @return the UAA url
|
||||
*/
|
||||
String getUaaUrl() {
|
||||
if (this.uaaUrl == null) {
|
||||
try {
|
||||
Map<?, ?> response = this.restTemplate.getForObject(this.cloudControllerUrl + "/info", Map.class);
|
||||
this.uaaUrl = (String) response.get("token_endpoint");
|
||||
}
|
||||
catch (HttpStatusCodeException ex) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.SERVICE_UNAVAILABLE,
|
||||
"Unable to fetch token keys from UAA");
|
||||
}
|
||||
}
|
||||
return this.uaaUrl;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import org.apache.commons.logging.Log;
|
||||
import org.apache.commons.logging.LogFactory;
|
||||
|
||||
import org.springframework.aot.hint.BindingReflectionHintsRegistrar;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.RuntimeHintsRegistrar;
|
||||
import org.springframework.aot.hint.annotation.Reflective;
|
||||
import org.springframework.aot.hint.annotation.ReflectiveRuntimeHintsRegistrar;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.Link;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperation;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryWebEndpointServletHandlerMapping.CloudFoundryWebEndpointServletHandlerMappingRuntimeHints;
|
||||
import org.springframework.boot.webmvc.actuate.endpoint.web.AbstractWebMvcEndpointHandlerMapping;
|
||||
import org.springframework.context.annotation.ImportRuntimeHints;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.ResponseBody;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.mvc.method.RequestMappingInfoHandlerMapping;
|
||||
|
||||
/**
|
||||
* A custom {@link RequestMappingInfoHandlerMapping} that makes web endpoints available on
|
||||
* Cloud Foundry specific URLs over HTTP using Spring MVC.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @author Phillip Webb
|
||||
* @author Brian Clozel
|
||||
*/
|
||||
@ImportRuntimeHints(CloudFoundryWebEndpointServletHandlerMappingRuntimeHints.class)
|
||||
class CloudFoundryWebEndpointServletHandlerMapping extends AbstractWebMvcEndpointHandlerMapping {
|
||||
|
||||
private static final Log logger = LogFactory.getLog(CloudFoundryWebEndpointServletHandlerMapping.class);
|
||||
|
||||
private final CloudFoundrySecurityInterceptor securityInterceptor;
|
||||
|
||||
private final EndpointLinksResolver linksResolver;
|
||||
|
||||
private final Collection<ExposableEndpoint<?>> allEndpoints;
|
||||
|
||||
CloudFoundryWebEndpointServletHandlerMapping(EndpointMapping endpointMapping,
|
||||
Collection<ExposableWebEndpoint> endpoints, EndpointMediaTypes endpointMediaTypes,
|
||||
CorsConfiguration corsConfiguration, CloudFoundrySecurityInterceptor securityInterceptor,
|
||||
Collection<ExposableEndpoint<?>> allEndpoints) {
|
||||
super(endpointMapping, endpoints, endpointMediaTypes, corsConfiguration, true);
|
||||
this.securityInterceptor = securityInterceptor;
|
||||
this.linksResolver = new EndpointLinksResolver(allEndpoints);
|
||||
this.allEndpoints = allEndpoints;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected ServletWebOperation wrapServletWebOperation(ExposableWebEndpoint endpoint, WebOperation operation,
|
||||
ServletWebOperation servletWebOperation) {
|
||||
return new SecureServletWebOperation(servletWebOperation, this.securityInterceptor, endpoint.getEndpointId());
|
||||
}
|
||||
|
||||
@Override
|
||||
protected LinksHandler getLinksHandler() {
|
||||
return new CloudFoundryLinksHandler();
|
||||
}
|
||||
|
||||
Collection<ExposableEndpoint<?>> getAllEndpoints() {
|
||||
return this.allEndpoints;
|
||||
}
|
||||
|
||||
class CloudFoundryLinksHandler implements LinksHandler {
|
||||
|
||||
@Override
|
||||
@ResponseBody
|
||||
@Reflective
|
||||
public Map<String, Map<String, Link>> links(HttpServletRequest request, HttpServletResponse response) {
|
||||
SecurityResponse securityResponse = CloudFoundryWebEndpointServletHandlerMapping.this.securityInterceptor
|
||||
.preHandle(request, null);
|
||||
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
|
||||
sendFailureResponse(response, securityResponse);
|
||||
}
|
||||
AccessLevel accessLevel = (AccessLevel) request.getAttribute(AccessLevel.REQUEST_ATTRIBUTE);
|
||||
Map<String, Link> filteredLinks = new LinkedHashMap<>();
|
||||
if (accessLevel == null) {
|
||||
return Collections.singletonMap("_links", filteredLinks);
|
||||
}
|
||||
Map<String, Link> links = CloudFoundryWebEndpointServletHandlerMapping.this.linksResolver
|
||||
.resolveLinks(request.getRequestURL().toString());
|
||||
filteredLinks = links.entrySet()
|
||||
.stream()
|
||||
.filter((e) -> e.getKey().equals("self") || accessLevel.isAccessAllowed(e.getKey()))
|
||||
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
|
||||
return Collections.singletonMap("_links", filteredLinks);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "Actuator root web endpoint";
|
||||
}
|
||||
|
||||
private void sendFailureResponse(HttpServletResponse response, SecurityResponse securityResponse) {
|
||||
try {
|
||||
response.sendError(securityResponse.getStatus().value(), securityResponse.getMessage());
|
||||
}
|
||||
catch (Exception ex) {
|
||||
logger.debug("Failed to send error response", ex);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* {@link ServletWebOperation} wrapper to add security.
|
||||
*/
|
||||
private static class SecureServletWebOperation implements ServletWebOperation {
|
||||
|
||||
private final ServletWebOperation delegate;
|
||||
|
||||
private final CloudFoundrySecurityInterceptor securityInterceptor;
|
||||
|
||||
private final EndpointId endpointId;
|
||||
|
||||
SecureServletWebOperation(ServletWebOperation delegate, CloudFoundrySecurityInterceptor securityInterceptor,
|
||||
EndpointId endpointId) {
|
||||
this.delegate = delegate;
|
||||
this.securityInterceptor = securityInterceptor;
|
||||
this.endpointId = endpointId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object handle(HttpServletRequest request, Map<String, String> body) {
|
||||
SecurityResponse securityResponse = this.securityInterceptor.preHandle(request, this.endpointId);
|
||||
if (!securityResponse.getStatus().equals(HttpStatus.OK)) {
|
||||
return new ResponseEntity<Object>(securityResponse.getMessage(), securityResponse.getStatus());
|
||||
}
|
||||
return this.delegate.handle(request, body);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
static class CloudFoundryWebEndpointServletHandlerMappingRuntimeHints implements RuntimeHintsRegistrar {
|
||||
|
||||
private final ReflectiveRuntimeHintsRegistrar reflectiveRegistrar = new ReflectiveRuntimeHintsRegistrar();
|
||||
|
||||
private final BindingReflectionHintsRegistrar bindingRegistrar = new BindingReflectionHintsRegistrar();
|
||||
|
||||
@Override
|
||||
public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
|
||||
this.reflectiveRegistrar.registerRuntimeHints(hints, CloudFoundryLinksHandler.class);
|
||||
this.bindingRegistrar.registerReflectionHints(hints.reflection(), Link.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
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 httpsURLConnection) {
|
||||
prepareHttpsConnection(httpsURLConnection);
|
||||
}
|
||||
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 static final class SkipHostnameVerifier implements HostnameVerifier {
|
||||
|
||||
@Override
|
||||
public boolean verify(String s, SSLSession sslSession) {
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
private static final 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,134 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
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.Base64;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
|
||||
|
||||
/**
|
||||
* Validator used to ensure that a signed {@link Token} has not been tampered with.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class TokenValidator {
|
||||
|
||||
private final CloudFoundrySecurityService securityService;
|
||||
|
||||
private Map<String, String> tokenKeys;
|
||||
|
||||
TokenValidator(CloudFoundrySecurityService cloudFoundrySecurityService) {
|
||||
this.securityService = cloudFoundrySecurityService;
|
||||
}
|
||||
|
||||
void validate(Token token) {
|
||||
validateAlgorithm(token);
|
||||
validateKeyIdAndSignature(token);
|
||||
validateExpiry(token);
|
||||
validateIssuer(token);
|
||||
validateAudience(token);
|
||||
}
|
||||
|
||||
private void validateAlgorithm(Token token) {
|
||||
String algorithm = token.getSignatureAlgorithm();
|
||||
if (algorithm == null) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE, "Signing algorithm cannot be null");
|
||||
}
|
||||
if (!algorithm.equals("RS256")) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM,
|
||||
"Signing algorithm " + algorithm + " not supported");
|
||||
}
|
||||
}
|
||||
|
||||
private void validateKeyIdAndSignature(Token token) {
|
||||
String keyId = token.getKeyId();
|
||||
if (this.tokenKeys == null || !hasValidKeyId(keyId)) {
|
||||
this.tokenKeys = this.securityService.fetchTokenKeys();
|
||||
if (!hasValidKeyId(keyId)) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_KEY_ID,
|
||||
"Key Id present in token header does not match");
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasValidSignature(token, this.tokenKeys.get(keyId))) {
|
||||
throw new CloudFoundryAuthorizationException(Reason.INVALID_SIGNATURE,
|
||||
"RSA Signature did not match content");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasValidKeyId(String tokenKey) {
|
||||
return this.tokenKeys.containsKey(tokenKey);
|
||||
}
|
||||
|
||||
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 = Base64.getDecoder().decode(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,20 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Auto-configuration for actuator Cloud Foundry concerns using Spring MVC.
|
||||
*/
|
||||
package org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
@@ -0,0 +1,3 @@
|
||||
# Endpoint Exposure Outcome Contributors
|
||||
org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor=\
|
||||
org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryEndpointExposureOutcomeContributor
|
||||
@@ -0,0 +1,2 @@
|
||||
org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.ReactiveCloudFoundryActuatorAutoConfiguration
|
||||
org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryActuatorAutoConfiguration
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link AccessLevel}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class AccessLevelTests {
|
||||
|
||||
@Test
|
||||
void accessToHealthEndpointShouldNotBeRestricted() {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("health")).isTrue();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("health")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessToInfoEndpointShouldNotBeRestricted() {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("info")).isTrue();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("info")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessToDiscoveryEndpointShouldNotBeRestricted() {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("")).isTrue();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void accessToAnyOtherEndpointShouldBeRestricted() {
|
||||
assertThat(AccessLevel.RESTRICTED.isAccessAllowed("env")).isFalse();
|
||||
assertThat(AccessLevel.FULL.isAccessAllowed("")).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryAuthorizationException}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryAuthorizationExceptionTests {
|
||||
|
||||
@Test
|
||||
void statusCodeForInvalidTokenReasonShouldBe401() {
|
||||
assertThat(createException(Reason.INVALID_TOKEN).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForInvalidIssuerReasonShouldBe401() {
|
||||
assertThat(createException(Reason.INVALID_ISSUER).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForInvalidAudienceReasonShouldBe401() {
|
||||
assertThat(createException(Reason.INVALID_AUDIENCE).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForInvalidSignatureReasonShouldBe401() {
|
||||
assertThat(createException(Reason.INVALID_SIGNATURE).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForMissingAuthorizationReasonShouldBe401() {
|
||||
assertThat(createException(Reason.MISSING_AUTHORIZATION).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForUnsupportedSignatureAlgorithmReasonShouldBe401() {
|
||||
assertThat(createException(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM).getStatusCode())
|
||||
.isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForTokenExpiredReasonShouldBe401() {
|
||||
assertThat(createException(Reason.TOKEN_EXPIRED).getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForAccessDeniedReasonShouldBe403() {
|
||||
assertThat(createException(Reason.ACCESS_DENIED).getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void statusCodeForServiceUnavailableReasonShouldBe503() {
|
||||
assertThat(createException(Reason.SERVICE_UNAVAILABLE).getStatusCode())
|
||||
.isEqualTo(HttpStatus.SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
private CloudFoundryAuthorizationException createException(Reason reason) {
|
||||
return new CloudFoundryAuthorizationException(reason, "message");
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.ConditionalOnAvailableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.Access;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.convert.ApplicationConversionService;
|
||||
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link ConditionalOnAvailableEndpoint @ConditionalOnAvailableEndpoint} when
|
||||
* running on Cloud Foundry.
|
||||
*
|
||||
* @author Brian Clozel
|
||||
*/
|
||||
class CloudFoundryConditionalOnAvailableEndpointTests {
|
||||
|
||||
private final ApplicationContextRunner contextRunner = new ApplicationContextRunner()
|
||||
.withUserConfiguration(AllEndpointsConfiguration.class)
|
||||
.withInitializer(
|
||||
(context) -> context.getEnvironment().setConversionService(new ApplicationConversionService()));
|
||||
|
||||
@Test
|
||||
void outcomeOnCloudFoundryShouldMatchAll() {
|
||||
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---")
|
||||
.run((context) -> assertThat(context).hasBean("info").hasBean("health").hasBean("spring").hasBean("test"));
|
||||
}
|
||||
|
||||
@Endpoint(id = "health")
|
||||
static class HealthEndpoint {
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "info")
|
||||
static class InfoEndpoint {
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "spring")
|
||||
static class SpringEndpoint {
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "test")
|
||||
static class TestEndpoint {
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "shutdown", defaultAccess = Access.NONE)
|
||||
static class ShutdownEndpoint {
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class AllEndpointsConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
HealthEndpoint health() {
|
||||
return new HealthEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
InfoEndpoint info() {
|
||||
return new InfoEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
SpringEndpoint spring() {
|
||||
return new SpringEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
TestEndpoint test() {
|
||||
return new TestEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
@ConditionalOnAvailableEndpoint
|
||||
ShutdownEndpoint shutdown() {
|
||||
return new ShutdownEndpoint();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.annotation.DiscoveredEndpoint;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryEndpointFilter}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryEndpointFilterTests {
|
||||
|
||||
private final CloudFoundryEndpointFilter filter = new CloudFoundryEndpointFilter();
|
||||
|
||||
@Test
|
||||
void matchIfDiscovererCloudFoundryShouldReturnFalse() {
|
||||
DiscoveredEndpoint<?> endpoint = mock(DiscoveredEndpoint.class);
|
||||
given(endpoint.wasDiscoveredBy(CloudFoundryWebEndpointDiscoverer.class)).willReturn(true);
|
||||
assertThat(this.filter.match(endpoint)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void matchIfDiscovererNotCloudFoundryShouldReturnFalse() {
|
||||
DiscoveredEndpoint<?> endpoint = mock(DiscoveredEndpoint.class);
|
||||
given(endpoint.wasDiscoveredBy(CloudFoundryWebEndpointDiscoverer.class)).willReturn(false);
|
||||
assertThat(this.filter.match(endpoint)).isFalse();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.function.Function;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.aot.hint.MemberCategory;
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.actuate.endpoint.InvocationContext;
|
||||
import org.springframework.boot.actuate.endpoint.SecurityContext;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.invoker.cache.CachingOperationInvokerAdvisor;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.PathMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperation;
|
||||
import org.springframework.boot.actuate.endpoint.web.annotation.EndpointWebExtension;
|
||||
import org.springframework.boot.actuate.health.HealthContributorRegistry;
|
||||
import org.springframework.boot.actuate.health.HealthEndpoint;
|
||||
import org.springframework.boot.actuate.health.HealthEndpointGroups;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryWebEndpointDiscoverer.CloudFoundryWebEndpointDiscovererRuntimeHints;
|
||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.convert.support.DefaultConversionService;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryWebEndpointDiscoverer}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class CloudFoundryWebEndpointDiscovererTests {
|
||||
|
||||
@Test
|
||||
void getEndpointsShouldAddCloudFoundryHealthExtension() {
|
||||
load(TestConfiguration.class, (discoverer) -> {
|
||||
Collection<ExposableWebEndpoint> endpoints = discoverer.getEndpoints();
|
||||
assertThat(endpoints).hasSize(2);
|
||||
for (ExposableWebEndpoint endpoint : endpoints) {
|
||||
if (endpoint.getEndpointId().equals(EndpointId.of("health"))) {
|
||||
WebOperation operation = findMainReadOperation(endpoint);
|
||||
assertThat(operation
|
||||
.invoke(new InvocationContext(mock(SecurityContext.class), Collections.emptyMap())))
|
||||
.isEqualTo("cf");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldRegisterHints() {
|
||||
RuntimeHints runtimeHints = new RuntimeHints();
|
||||
new CloudFoundryWebEndpointDiscovererRuntimeHints().registerHints(runtimeHints, getClass().getClassLoader());
|
||||
assertThat(RuntimeHintsPredicates.reflection()
|
||||
.onType(CloudFoundryEndpointFilter.class)
|
||||
.withMemberCategories(MemberCategory.INVOKE_DECLARED_CONSTRUCTORS)).accepts(runtimeHints);
|
||||
}
|
||||
|
||||
private WebOperation findMainReadOperation(ExposableWebEndpoint endpoint) {
|
||||
for (WebOperation operation : endpoint.getOperations()) {
|
||||
if (operation.getRequestPredicate().getPath().equals("health")) {
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("No main read operation found from " + endpoint.getOperations());
|
||||
}
|
||||
|
||||
private void load(Class<?> configuration, Consumer<CloudFoundryWebEndpointDiscoverer> consumer) {
|
||||
load((id) -> null, EndpointId::toString, configuration, consumer);
|
||||
}
|
||||
|
||||
private void load(Function<EndpointId, Long> timeToLive, PathMapper endpointPathMapper, Class<?> configuration,
|
||||
Consumer<CloudFoundryWebEndpointDiscoverer> consumer) {
|
||||
try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext(configuration)) {
|
||||
ConversionServiceParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
|
||||
DefaultConversionService.getSharedInstance());
|
||||
EndpointMediaTypes mediaTypes = new EndpointMediaTypes(Collections.singletonList("application/json"),
|
||||
Collections.singletonList("application/json"));
|
||||
CloudFoundryWebEndpointDiscoverer discoverer = new CloudFoundryWebEndpointDiscoverer(context,
|
||||
parameterMapper, mediaTypes, Collections.singletonList(endpointPathMapper),
|
||||
Collections.singleton(new CachingOperationInvokerAdvisor(timeToLive)), Collections.emptyList(),
|
||||
Collections.emptyList());
|
||||
consumer.accept(discoverer);
|
||||
}
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class TestConfiguration {
|
||||
|
||||
@Bean
|
||||
TestEndpoint testEndpoint() {
|
||||
return new TestEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestEndpointWebExtension testEndpointWebExtension() {
|
||||
return new TestEndpointWebExtension();
|
||||
}
|
||||
|
||||
@Bean
|
||||
HealthEndpoint healthEndpoint() {
|
||||
HealthContributorRegistry registry = mock(HealthContributorRegistry.class);
|
||||
HealthEndpointGroups groups = mock(HealthEndpointGroups.class);
|
||||
return new HealthEndpoint(registry, groups, null);
|
||||
}
|
||||
|
||||
@Bean
|
||||
HealthEndpointWebExtension healthEndpointWebExtension() {
|
||||
return new HealthEndpointWebExtension();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestHealthEndpointCloudFoundryExtension testHealthEndpointCloudFoundryExtension() {
|
||||
return new TestHealthEndpointCloudFoundryExtension();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "test")
|
||||
static class TestEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
Object getAll() {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EndpointWebExtension(endpoint = TestEndpoint.class)
|
||||
static class TestEndpointWebExtension {
|
||||
|
||||
@ReadOperation
|
||||
Object getAll() {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EndpointWebExtension(endpoint = HealthEndpoint.class)
|
||||
static class HealthEndpointWebExtension {
|
||||
|
||||
@ReadOperation
|
||||
Object getAll() {
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@EndpointCloudFoundryExtension(endpoint = HealthEndpoint.class)
|
||||
static class TestHealthEndpointCloudFoundryExtension {
|
||||
|
||||
@ReadOperation
|
||||
Object getAll() {
|
||||
return "cf";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure;
|
||||
|
||||
import java.util.Base64;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Tests for {@link Token}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class TokenTests {
|
||||
|
||||
@Test
|
||||
void invalidJwtShouldThrowException() {
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(() -> new Token("invalid-token"))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidJwtClaimsShouldThrowException() {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "invalid-claims";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> new Token(Base64.getEncoder().encodeToString(header.getBytes()) + "."
|
||||
+ Base64.getEncoder().encodeToString(claims.getBytes())))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void invalidJwtHeaderShouldThrowException() {
|
||||
String header = "invalid-header";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> new Token(Base64.getEncoder().encodeToString(header.getBytes()) + "."
|
||||
+ Base64.getEncoder().encodeToString(claims.getBytes())))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptyJwtSignatureShouldThrowException() {
|
||||
String token = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ.";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(() -> new Token(token))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validJwt() {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
String content = Base64.getEncoder().encodeToString(header.getBytes()) + "."
|
||||
+ Base64.getEncoder().encodeToString(claims.getBytes());
|
||||
String signature = Base64.getEncoder().encodeToString("signature".getBytes());
|
||||
Token token = new Token(content + "." + signature);
|
||||
assertThat(token.getExpiry()).isEqualTo(2147483647);
|
||||
assertThat(token.getIssuer()).isEqualTo("http://localhost:8080/uaa/oauth/token");
|
||||
assertThat(token.getSignatureAlgorithm()).isEqualTo("RS256");
|
||||
assertThat(token.getKeyId()).isEqualTo("key-id");
|
||||
assertThat(token.getContent()).isEqualTo(content.getBytes());
|
||||
assertThat(token.getSignature()).isEqualTo(Base64.getDecoder().decode(signature));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getSignatureAlgorithmWhenAlgIsNullShouldThrowException() {
|
||||
String header = "{\"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
Token token = createToken(header, claims);
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getSignatureAlgorithm)
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getIssuerWhenIssIsNullShouldThrowException() {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647}";
|
||||
Token token = createToken(header, claims);
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getIssuer)
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getKidWhenKidIsNullShouldThrowException() {
|
||||
String header = "{\"alg\": \"RS256\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647}";
|
||||
Token token = createToken(header, claims);
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getKeyId)
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getExpiryWhenExpIsNullShouldThrowException() {
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"key-id\", \"typ\": \"JWT\"}";
|
||||
String claims = "{\"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
Token token = createToken(header, claims);
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class).isThrownBy(token::getExpiry)
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
private Token createToken(String header, String claims) {
|
||||
Token token = new Token(Base64.getEncoder().encodeToString(header.getBytes()) + "."
|
||||
+ Base64.getEncoder().encodeToString(claims.getBytes()) + "."
|
||||
+ Base64.getEncoder().encodeToString("signature".getBytes()));
|
||||
return token;
|
||||
}
|
||||
|
||||
private Consumer<CloudFoundryAuthorizationException> reasonRequirement(Reason reason) {
|
||||
return (ex) -> assertThat(ex.getReason()).isEqualTo(reason);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.time.Duration;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.health.CompositeHealth;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthComponent;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.reactive.ReactiveSecurityAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
|
||||
import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration;
|
||||
import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryReactiveHealthEndpointWebExtension}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryReactiveHealthEndpointWebExtensionTests {
|
||||
|
||||
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
|
||||
.withPropertyValues("VCAP_APPLICATION={}")
|
||||
.withConfiguration(AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class,
|
||||
WebFluxAutoConfiguration.class, JacksonAutoConfiguration.class,
|
||||
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
|
||||
ReactiveCloudFoundryActuatorAutoConfigurationTests.WebClientCustomizerConfig.class,
|
||||
WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
|
||||
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
|
||||
HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
|
||||
ReactiveCloudFoundryActuatorAutoConfiguration.class))
|
||||
.withUserConfiguration(TestHealthIndicator.class, UserDetailsServiceConfiguration.class);
|
||||
|
||||
@Test
|
||||
void healthComponentsAlwaysPresent() {
|
||||
this.contextRunner.run((context) -> {
|
||||
CloudFoundryReactiveHealthEndpointWebExtension extension = context
|
||||
.getBean(CloudFoundryReactiveHealthEndpointWebExtension.class);
|
||||
HealthComponent body = extension.health(ApiVersion.V3).block(Duration.ofSeconds(30)).getBody();
|
||||
HealthComponent health = ((CompositeHealth) body).getComponents().entrySet().iterator().next().getValue();
|
||||
assertThat(((Health) health).getDetails()).containsEntry("spring", "boot");
|
||||
});
|
||||
}
|
||||
|
||||
private static final class TestHealthIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return Health.up().withDetail("spring", "boot").build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class UserDetailsServiceConfiguration {
|
||||
|
||||
@Bean
|
||||
MapReactiveUserDetailsService userDetailsService() {
|
||||
return new MapReactiveUserDetailsService(
|
||||
User.withUsername("alice").password("secret").roles("admin").build());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
|
||||
import org.springframework.boot.actuate.endpoint.web.Link;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.CloudFoundryWebFluxEndpointHandlerMapping.CloudFoundryLinksHandler;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.reactive.CloudFoundryWebFluxEndpointHandlerMapping.CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryWebFluxEndpointHandlerMapping}.
|
||||
*
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class CloudFoundryWebFluxEndpointHandlerMappingTests {
|
||||
|
||||
@Test
|
||||
void shouldRegisterHints() {
|
||||
RuntimeHints runtimeHints = new RuntimeHints();
|
||||
new CloudFoundryWebFluxEndpointHandlerMappingRuntimeHints().registerHints(runtimeHints,
|
||||
getClass().getClassLoader());
|
||||
assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(CloudFoundryLinksHandler.class, "links"))
|
||||
.accepts(runtimeHints);
|
||||
assertThat(RuntimeHintsPredicates.reflection().onType(Link.class)).accepts(runtimeHints);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Selector;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.reactor.netty.autoconfigure.NettyReactiveWebServerAutoConfiguration;
|
||||
import org.springframework.boot.test.context.assertj.AssertableReactiveWebApplicationContext;
|
||||
import org.springframework.boot.test.context.runner.ContextConsumer;
|
||||
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
|
||||
import org.springframework.boot.web.server.reactive.context.AnnotationConfigReactiveWebServerApplicationContext;
|
||||
import org.springframework.boot.webflux.autoconfigure.HttpHandlerAutoConfiguration;
|
||||
import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.convert.support.DefaultConversionService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.willThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryWebFluxEndpointHandlerMapping}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @author Stephane Nicoll
|
||||
*/
|
||||
class CloudFoundryWebFluxEndpointIntegrationTests {
|
||||
|
||||
private final ReactiveTokenValidator tokenValidator = mock(ReactiveTokenValidator.class);
|
||||
|
||||
private final ReactiveCloudFoundrySecurityService securityService = mock(ReactiveCloudFoundrySecurityService.class);
|
||||
|
||||
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner(
|
||||
AnnotationConfigReactiveWebServerApplicationContext::new)
|
||||
.withConfiguration(AutoConfigurations.of(WebFluxAutoConfiguration.class, HttpHandlerAutoConfiguration.class,
|
||||
NettyReactiveWebServerAutoConfiguration.class))
|
||||
.withUserConfiguration(TestEndpointConfiguration.class)
|
||||
.withBean(ReactiveTokenValidator.class, () -> this.tokenValidator)
|
||||
.withBean(ReactiveCloudFoundrySecurityService.class, () -> this.securityService)
|
||||
.withPropertyValues("server.port=0");
|
||||
|
||||
@Test
|
||||
void operationWithSecurityInterceptorForbidden() {
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
|
||||
this.contextRunner.run(withWebTestClient((client) -> client.get()
|
||||
.uri("/cfApplication/test")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isEqualTo(HttpStatus.FORBIDDEN)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void operationWithSecurityInterceptorSuccess() {
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
|
||||
this.contextRunner.run(withWebTestClient((client) -> client.get()
|
||||
.uri("/cfApplication/test")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isEqualTo(HttpStatus.OK)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseToOptionsRequestIncludesCorsHeaders() {
|
||||
this.contextRunner.run(withWebTestClient((client) -> client.options()
|
||||
.uri("/cfApplication/test")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Access-Control-Request-Method", "POST")
|
||||
.header("Origin", "https://example.com")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectHeader()
|
||||
.valueEquals("Access-Control-Allow-Origin", "https://example.com")
|
||||
.expectHeader()
|
||||
.valueEquals("Access-Control-Allow-Methods", "GET,POST")));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksToOtherEndpointsWithFullAccess() {
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.FULL));
|
||||
this.contextRunner.run(withWebTestClient((client) -> client.get()
|
||||
.uri("/cfApplication")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody()
|
||||
.jsonPath("_links.length()")
|
||||
.isEqualTo(5)
|
||||
.jsonPath("_links.self.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.self.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.info.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.info.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.env.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.env.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.test.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.test.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.test-part.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.test-part.templated")
|
||||
.isEqualTo(true)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksToOtherEndpointsForbidden() {
|
||||
CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
|
||||
"invalid-token");
|
||||
willThrow(exception).given(this.tokenValidator).validate(any());
|
||||
this.contextRunner.run(withWebTestClient((client) -> client.get()
|
||||
.uri("/cfApplication")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isUnauthorized()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksToOtherEndpointsWithRestrictedAccess() {
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(Mono.just(AccessLevel.RESTRICTED));
|
||||
this.contextRunner.run(withWebTestClient((client) -> client.get()
|
||||
.uri("/cfApplication")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody()
|
||||
.jsonPath("_links.length()")
|
||||
.isEqualTo(2)
|
||||
.jsonPath("_links.self.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.self.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.info.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.info.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.env")
|
||||
.doesNotExist()
|
||||
.jsonPath("_links.test")
|
||||
.doesNotExist()
|
||||
.jsonPath("_links.test-part")
|
||||
.doesNotExist()));
|
||||
}
|
||||
|
||||
private ContextConsumer<AssertableReactiveWebApplicationContext> withWebTestClient(
|
||||
Consumer<WebTestClient> clientConsumer) {
|
||||
return (context) -> {
|
||||
int port = ((AnnotationConfigReactiveWebServerApplicationContext) context.getSourceApplicationContext())
|
||||
.getWebServer()
|
||||
.getPort();
|
||||
clientConsumer.accept(WebTestClient.bindToServer()
|
||||
.baseUrl("http://localhost:" + port)
|
||||
.responseTimeout(Duration.ofMinutes(5))
|
||||
.build());
|
||||
};
|
||||
}
|
||||
|
||||
private String mockAccessToken() {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
|
||||
+ Base64.getEncoder().encodeToString("signature".getBytes());
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class CloudFoundryReactiveConfiguration {
|
||||
|
||||
@Bean
|
||||
CloudFoundrySecurityInterceptor interceptor(ReactiveTokenValidator tokenValidator,
|
||||
ReactiveCloudFoundrySecurityService securityService) {
|
||||
return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
|
||||
}
|
||||
|
||||
@Bean
|
||||
EndpointMediaTypes EndpointMediaTypes() {
|
||||
return new EndpointMediaTypes(Collections.singletonList("application/json"),
|
||||
Collections.singletonList("application/json"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
CloudFoundryWebFluxEndpointHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
|
||||
WebEndpointDiscoverer webEndpointDiscoverer, EndpointMediaTypes endpointMediaTypes,
|
||||
CloudFoundrySecurityInterceptor interceptor) {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.setAllowedOrigins(Arrays.asList("https://example.com"));
|
||||
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST"));
|
||||
Collection<ExposableWebEndpoint> webEndpoints = webEndpointDiscoverer.getEndpoints();
|
||||
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>(webEndpoints);
|
||||
return new CloudFoundryWebFluxEndpointHandlerMapping(new EndpointMapping("/cfApplication"), webEndpoints,
|
||||
endpointMediaTypes, corsConfiguration, interceptor, allEndpoints);
|
||||
}
|
||||
|
||||
@Bean
|
||||
WebEndpointDiscoverer webEndpointDiscoverer(ApplicationContext applicationContext,
|
||||
EndpointMediaTypes endpointMediaTypes) {
|
||||
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
|
||||
DefaultConversionService.getSharedInstance());
|
||||
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null,
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
|
||||
}
|
||||
|
||||
@Bean
|
||||
EndpointDelegate endpointDelegate() {
|
||||
return mock(EndpointDelegate.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "test")
|
||||
static class TestEndpoint {
|
||||
|
||||
private final EndpointDelegate endpointDelegate;
|
||||
|
||||
TestEndpoint(EndpointDelegate endpointDelegate) {
|
||||
this.endpointDelegate = endpointDelegate;
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readAll() {
|
||||
return Collections.singletonMap("All", true);
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readPart(@Selector String part) {
|
||||
return Collections.singletonMap("part", part);
|
||||
}
|
||||
|
||||
@WriteOperation
|
||||
void write(String foo, String bar) {
|
||||
this.endpointDelegate.write(foo, bar);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "env")
|
||||
static class TestEnvEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readAll() {
|
||||
return Collections.singletonMap("All", true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "info")
|
||||
static class TestInfoEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readAll() {
|
||||
return Collections.singletonMap("All", true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Import(CloudFoundryReactiveConfiguration.class)
|
||||
static class TestEndpointConfiguration {
|
||||
|
||||
@Bean
|
||||
TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) {
|
||||
return new TestEndpoint(endpointDelegate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestInfoEndpoint testInfoEnvEndpoint() {
|
||||
return new TestInfoEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestEnvEndpoint testEnvEndpoint() {
|
||||
return new TestEnvEndpoint();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface EndpointDelegate {
|
||||
|
||||
void write();
|
||||
|
||||
void write(String foo, String bar);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.time.Duration;
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import javax.net.ssl.SSLException;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import org.assertj.core.api.InstanceOfAssertFactories;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.netty.http.HttpResources;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.info.InfoContributorAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperation;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryInfoEndpointWebExtension;
|
||||
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.reactive.ReactiveSecurityAutoConfiguration;
|
||||
import org.springframework.boot.ssl.SslBundle;
|
||||
import org.springframework.boot.ssl.jks.JksSslStoreBundle;
|
||||
import org.springframework.boot.ssl.jks.JksSslStoreDetails;
|
||||
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
|
||||
import org.springframework.boot.testsupport.classpath.resources.WithPackageResources;
|
||||
import org.springframework.boot.testsupport.classpath.resources.WithResource;
|
||||
import org.springframework.boot.webclient.WebClientCustomizer;
|
||||
import org.springframework.boot.webclient.autoconfigure.WebClientAutoConfiguration;
|
||||
import org.springframework.boot.webflux.autoconfigure.WebFluxAutoConfiguration;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatusCode;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
import org.springframework.security.core.userdetails.MapReactiveUserDetailsService;
|
||||
import org.springframework.security.core.userdetails.User;
|
||||
import org.springframework.security.web.server.SecurityWebFilterChain;
|
||||
import org.springframework.security.web.server.WebFilterChainProxy;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Tests for {@link ReactiveCloudFoundryActuatorAutoConfiguration}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class ReactiveCloudFoundryActuatorAutoConfigurationTests {
|
||||
|
||||
private static final String V2_JSON = ApiVersion.V2.getProducedMimeType().toString();
|
||||
|
||||
private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString();
|
||||
|
||||
private final ReactiveWebApplicationContextRunner contextRunner = new ReactiveWebApplicationContextRunner()
|
||||
.withConfiguration(
|
||||
AutoConfigurations.of(ReactiveSecurityAutoConfiguration.class, WebFluxAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class, HttpMessageConvertersAutoConfiguration.class,
|
||||
PropertyPlaceholderAutoConfiguration.class, WebClientCustomizerConfig.class,
|
||||
WebClientAutoConfiguration.class, ManagementContextAutoConfiguration.class,
|
||||
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class,
|
||||
HealthContributorAutoConfiguration.class, HealthEndpointAutoConfiguration.class,
|
||||
InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
|
||||
ProjectInfoAutoConfiguration.class, ReactiveCloudFoundryActuatorAutoConfiguration.class))
|
||||
.withUserConfiguration(UserDetailsServiceConfiguration.class);
|
||||
|
||||
private static final String BASE_PATH = "/cloudfoundryapplication";
|
||||
|
||||
@AfterEach
|
||||
void close() {
|
||||
HttpResources.reset();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActive() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
assertThat(handlerMapping).extracting("endpointMapping.path").isEqualTo("/cloudfoundryapplication");
|
||||
assertThat(handlerMapping)
|
||||
.extracting("corsConfiguration", InstanceOfAssertFactories.type(CorsConfiguration.class))
|
||||
.satisfies((corsConfiguration) -> {
|
||||
assertThat(corsConfiguration.getAllowedOrigins()).contains("*");
|
||||
assertThat(corsConfiguration.getAllowedMethods())
|
||||
.containsAll(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
|
||||
assertThat(corsConfiguration.getAllowedHeaders())
|
||||
.containsAll(Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudfoundryapplicationProducesActuatorMediaType() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
WebTestClient webTestClient = WebTestClient.bindToApplicationContext(context).build();
|
||||
webTestClient.get().uri("/cloudfoundryapplication").header("Content-Type", V2_JSON + ";charset=UTF-8");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActiveSetsApplicationId() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> assertThat(getHandlerMapping(context)).extracting("securityInterceptor.applicationId")
|
||||
.isEqualTo("my-app-id"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActiveSetsCloudControllerUrl() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> assertThat(getHandlerMapping(context))
|
||||
.extracting("securityInterceptor.cloudFoundrySecurityService.cloudControllerUrl")
|
||||
.isEqualTo("https://my-cloud-controller.com"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() {
|
||||
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
|
||||
.run((context) -> assertThat(context.getBean("cloudFoundryWebFluxEndpointHandlerMapping",
|
||||
CloudFoundryWebFluxEndpointHandlerMapping.class))
|
||||
.extracting("securityInterceptor.cloudFoundrySecurityService")
|
||||
.isNull());
|
||||
}
|
||||
|
||||
@Test
|
||||
@SuppressWarnings("unchecked")
|
||||
void cloudFoundryPathsIgnoredBySpringSecurity() {
|
||||
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
assertThat(context.getBean(WebFilterChainProxy.class))
|
||||
.extracting("filters", InstanceOfAssertFactories.list(SecurityWebFilterChain.class))
|
||||
.satisfies((filters) -> {
|
||||
Boolean cfBaseRequestMatches = getMatches(filters, BASE_PATH);
|
||||
Boolean cfBaseWithTrailingSlashRequestMatches = getMatches(filters, BASE_PATH + "/");
|
||||
Boolean cfRequestMatches = getMatches(filters, BASE_PATH + "/test");
|
||||
Boolean cfRequestWithAdditionalPathMatches = getMatches(filters, BASE_PATH + "/test/a");
|
||||
Boolean otherCfRequestMatches = getMatches(filters, BASE_PATH + "/other-path");
|
||||
Boolean otherRequestMatches = getMatches(filters, "/some-other-path");
|
||||
assertThat(cfBaseRequestMatches).isTrue();
|
||||
assertThat(cfBaseWithTrailingSlashRequestMatches).isTrue();
|
||||
assertThat(cfRequestMatches).isTrue();
|
||||
assertThat(cfRequestWithAdditionalPathMatches).isTrue();
|
||||
assertThat(otherCfRequestMatches).isFalse();
|
||||
assertThat(otherRequestMatches).isFalse();
|
||||
otherRequestMatches = filters.get(1)
|
||||
.matches(MockServerWebExchange.from(MockServerHttpRequest.get("/some-other-path").build()))
|
||||
.block(Duration.ofSeconds(30));
|
||||
assertThat(otherRequestMatches).isTrue();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private static Boolean getMatches(List<? extends SecurityWebFilterChain> filters, String urlTemplate) {
|
||||
return filters.get(0)
|
||||
.matches(MockServerWebExchange.from(MockServerHttpRequest.get(urlTemplate).build()))
|
||||
.block(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformInactive() {
|
||||
this.contextRunner
|
||||
.run((context) -> assertThat(context.containsBean("cloudFoundryWebFluxEndpointHandlerMapping")).isFalse());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryManagementEndpointsDisabled() {
|
||||
this.contextRunner.withPropertyValues("VCAP_APPLICATION=---", "management.cloudfoundry.enabled:false")
|
||||
.run((context) -> assertThat(context.containsBean("cloudFoundryWebFluxEndpointHandlerMapping")).isFalse());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allEndpointsAvailableUnderCloudFoundryWithoutEnablingWebIncludes() {
|
||||
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
|
||||
List<EndpointId> endpointIds = endpoints.stream().map(ExposableWebEndpoint::getEndpointId).toList();
|
||||
assertThat(endpointIds).contains(EndpointId.of("test"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointPathCustomizationIsNotApplied() {
|
||||
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebFluxEndpointHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
|
||||
ExposableWebEndpoint endpoint = endpoints.stream()
|
||||
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
|
||||
.findFirst()
|
||||
.get();
|
||||
assertThat(endpoint.getOperations()).hasSize(1);
|
||||
WebOperation operation = endpoint.getOperations().iterator().next();
|
||||
assertThat(operation.getRequestPredicate().getPath()).isEqualTo("test");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthEndpointInvokerShouldBeCloudFoundryWebExtension() {
|
||||
this.contextRunner.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
Collection<ExposableWebEndpoint> endpoints = getHandlerMapping(context).getEndpoints();
|
||||
ExposableWebEndpoint endpoint = endpoints.iterator().next();
|
||||
assertThat(endpoint.getOperations()).hasSize(2);
|
||||
WebOperation webOperation = findOperationWithRequestPath(endpoint, "health");
|
||||
assertThat(webOperation).extracting("invoker")
|
||||
.extracting("target")
|
||||
.isInstanceOf(CloudFoundryReactiveHealthEndpointWebExtension.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithResource(name = "git.properties", content = """
|
||||
#Generated by Git-Commit-Id-Plugin
|
||||
#Thu May 23 09:26:42 BST 2013
|
||||
git.commit.id.abbrev=e02a4f3
|
||||
git.commit.user.email=dsyer@vmware.com
|
||||
git.commit.message.full=Update Spring
|
||||
git.commit.id=e02a4f3b6f452cdbf6dd311f1362679eb4c31ced
|
||||
git.commit.message.short=Update Spring
|
||||
git.commit.user.name=Dave Syer
|
||||
git.build.user.name=Dave Syer
|
||||
git.build.user.email=dsyer@vmware.com
|
||||
git.branch=develop
|
||||
git.commit.time=2013-04-24T08\\:42\\:13+0100
|
||||
git.build.time=2013-05-23T09\\:26\\:42+0100
|
||||
""")
|
||||
@SuppressWarnings("unchecked")
|
||||
void gitFullDetailsAlwaysPresent() {
|
||||
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---").run((context) -> {
|
||||
CloudFoundryInfoEndpointWebExtension extension = context
|
||||
.getBean(CloudFoundryInfoEndpointWebExtension.class);
|
||||
Map<String, Object> git = (Map<String, Object>) extension.info().get("git");
|
||||
Map<String, Object> commit = (Map<String, Object>) git.get("commit");
|
||||
assertThat(commit).hasSize(4);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithPackageResources("test.jks")
|
||||
void skipSslValidation() throws IOException {
|
||||
JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("JKS", null, "classpath:test.jks", "secret");
|
||||
SslBundle sslBundle = SslBundle.of(new JksSslStoreBundle(keyStoreDetails, keyStoreDetails));
|
||||
try (MockWebServer server = new MockWebServer()) {
|
||||
server.useHttps(sslBundle.createSslContext().getSocketFactory(), false);
|
||||
server.enqueue(new MockResponse().setResponseCode(204));
|
||||
server.start();
|
||||
this.contextRunner.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com",
|
||||
"management.cloudfoundry.skip-ssl-validation:true")
|
||||
.run((context) -> assertThat(getHandlerMapping(context))
|
||||
.extracting("securityInterceptor.cloudFoundrySecurityService.webClient",
|
||||
InstanceOfAssertFactories.type(WebClient.class))
|
||||
.satisfies((webClient) -> {
|
||||
ResponseEntity<Void> response = webClient.get()
|
||||
.uri(server.url("/").uri())
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block(Duration.ofSeconds(30));
|
||||
assertThat(response.getStatusCode()).isEqualTo(HttpStatusCode.valueOf(204));
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithPackageResources("test.jks")
|
||||
void sslValidationNotSkippedByDefault() throws IOException {
|
||||
JksSslStoreDetails keyStoreDetails = new JksSslStoreDetails("JKS", null, "classpath:test.jks", "secret");
|
||||
SslBundle sslBundle = SslBundle.of(new JksSslStoreBundle(keyStoreDetails, keyStoreDetails));
|
||||
try (MockWebServer server = new MockWebServer()) {
|
||||
server.useHttps(sslBundle.createSslContext().getSocketFactory(), false);
|
||||
server.enqueue(new MockResponse().setResponseCode(204));
|
||||
server.start();
|
||||
this.contextRunner.withConfiguration(AutoConfigurations.of(HealthEndpointAutoConfiguration.class))
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> assertThat(getHandlerMapping(context))
|
||||
.extracting("securityInterceptor.cloudFoundrySecurityService.webClient",
|
||||
InstanceOfAssertFactories.type(WebClient.class))
|
||||
.satisfies((webClient) -> assertThatExceptionOfType(RuntimeException.class)
|
||||
.isThrownBy(() -> webClient.get()
|
||||
.uri(server.url("/").uri())
|
||||
.retrieve()
|
||||
.toBodilessEntity()
|
||||
.block(Duration.ofSeconds(30)))
|
||||
.withCauseInstanceOf(SSLException.class)));
|
||||
}
|
||||
}
|
||||
|
||||
private CloudFoundryWebFluxEndpointHandlerMapping getHandlerMapping(ApplicationContext context) {
|
||||
return context.getBean("cloudFoundryWebFluxEndpointHandlerMapping",
|
||||
CloudFoundryWebFluxEndpointHandlerMapping.class);
|
||||
}
|
||||
|
||||
private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, String requestPath) {
|
||||
for (WebOperation operation : endpoint.getOperations()) {
|
||||
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
|
||||
if (predicate.getPath().equals(requestPath) && predicate.getProduces().contains(V3_JSON)) {
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"No operation found with request path " + requestPath + " from " + endpoint.getOperations());
|
||||
}
|
||||
|
||||
@Endpoint(id = "test")
|
||||
static class TestEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
String hello() {
|
||||
return "hello world";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class WebClientCustomizerConfig {
|
||||
|
||||
@Bean
|
||||
WebClientCustomizer webClientCustomizer() {
|
||||
return mock(WebClientCustomizer.class);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
static class UserDetailsServiceConfiguration {
|
||||
|
||||
@Bean
|
||||
MapReactiveUserDetailsService userDetailsService() {
|
||||
return new MapReactiveUserDetailsService(
|
||||
User.withUsername("alice").password("secret").roles("admin").build());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.http.server.reactive.MockServerHttpRequest;
|
||||
import org.springframework.mock.web.server.MockServerWebExchange;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundrySecurityInterceptor}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReactiveCloudFoundrySecurityInterceptorTests {
|
||||
|
||||
@Mock
|
||||
private ReactiveTokenValidator tokenValidator;
|
||||
|
||||
@Mock
|
||||
private ReactiveCloudFoundrySecurityService securityService;
|
||||
|
||||
private CloudFoundrySecurityInterceptor interceptor;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, "my-app-id");
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenRequestIsPreFlightShouldBeOk() {
|
||||
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.options("/a")
|
||||
.header(HttpHeaders.ORIGIN, "https://example.com")
|
||||
.header(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET")
|
||||
.build());
|
||||
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
|
||||
.consumeNextWith((response) -> assertThat(response.getStatus()).isEqualTo(HttpStatus.OK))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenTokenIsMissingShouldReturnMissingAuthorization() {
|
||||
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.get("/a").build());
|
||||
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
|
||||
.consumeNextWith(
|
||||
(response) -> assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus()))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenTokenIsNotBearerShouldReturnMissingAuthorization() {
|
||||
MockServerWebExchange request = MockServerWebExchange
|
||||
.from(MockServerHttpRequest.get("/a").header(HttpHeaders.AUTHORIZATION, mockAccessToken()).build());
|
||||
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
|
||||
.consumeNextWith(
|
||||
(response) -> assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus()))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenApplicationIdIsNullShouldReturnError() {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, null);
|
||||
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.get("/a")
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
|
||||
.build());
|
||||
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
|
||||
.consumeErrorWith((ex) -> assertThat(((CloudFoundryAuthorizationException) ex).getReason())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE))
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnError() {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null, "my-app-id");
|
||||
MockServerWebExchange request = MockServerWebExchange
|
||||
.from(MockServerHttpRequest.get("/a").header(HttpHeaders.AUTHORIZATION, mockAccessToken()).build());
|
||||
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
|
||||
.consumeErrorWith((ex) -> assertThat(((CloudFoundryAuthorizationException) ex).getReason())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE))
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenAccessIsNotAllowedShouldReturnAccessDenied() {
|
||||
given(this.securityService.getAccessLevel(mockAccessToken(), "my-app-id"))
|
||||
.willReturn(Mono.just(AccessLevel.RESTRICTED));
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
MockServerWebExchange request = MockServerWebExchange.from(MockServerHttpRequest.get("/a")
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
|
||||
.build());
|
||||
StepVerifier.create(this.interceptor.preHandle(request, "/a"))
|
||||
.consumeNextWith((response) -> assertThat(response.getStatus()).isEqualTo(Reason.ACCESS_DENIED.getStatus()))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleSuccessfulWithFullAccess() {
|
||||
String accessToken = mockAccessToken();
|
||||
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(Mono.just(AccessLevel.FULL));
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/a")
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
|
||||
.build());
|
||||
StepVerifier.create(this.interceptor.preHandle(exchange, "/a")).consumeNextWith((response) -> {
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
assertThat((AccessLevel) exchange.getAttribute("cloudFoundryAccessLevel")).isEqualTo(AccessLevel.FULL);
|
||||
}).expectComplete().verify(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleSuccessfulWithRestrictedAccess() {
|
||||
String accessToken = mockAccessToken();
|
||||
given(this.securityService.getAccessLevel(accessToken, "my-app-id"))
|
||||
.willReturn(Mono.just(AccessLevel.RESTRICTED));
|
||||
given(this.tokenValidator.validate(any())).willReturn(Mono.empty());
|
||||
MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/info")
|
||||
.header(HttpHeaders.AUTHORIZATION, "bearer " + mockAccessToken())
|
||||
.build());
|
||||
StepVerifier.create(this.interceptor.preHandle(exchange, "info")).consumeNextWith((response) -> {
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
assertThat((AccessLevel) exchange.getAttribute("cloudFoundryAccessLevel"))
|
||||
.isEqualTo(AccessLevel.RESTRICTED);
|
||||
}).expectComplete().verify(Duration.ofSeconds(30));
|
||||
}
|
||||
|
||||
private String mockAccessToken() {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
|
||||
+ Base64.getEncoder().encodeToString("signature".getBytes());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse;
|
||||
import okhttp3.mockwebserver.MockWebServer;
|
||||
import okhttp3.mockwebserver.RecordedRequest;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import reactor.test.StepVerifier;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link ReactiveCloudFoundrySecurityService}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class ReactiveCloudFoundrySecurityServiceTests {
|
||||
|
||||
private static final String CLOUD_CONTROLLER = "/my-cloud-controller.com";
|
||||
|
||||
private static final String CLOUD_CONTROLLER_PERMISSIONS = CLOUD_CONTROLLER + "/v2/apps/my-app-id/permissions";
|
||||
|
||||
private static final String UAA_URL = "https://my-cloud-controller.com/uaa";
|
||||
|
||||
private ReactiveCloudFoundrySecurityService securityService;
|
||||
|
||||
private MockWebServer server;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
this.server = new MockWebServer();
|
||||
WebClient.Builder builder = WebClient.builder().baseUrl(this.server.url("/").toString());
|
||||
this.securityService = new ReactiveCloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void shutdown() throws Exception {
|
||||
this.server.shutdown();
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenSpaceDeveloperShouldReturnFull() throws Exception {
|
||||
String responseBody = "{\"read_sensitive_data\": true,\"read_basic_data\": true}";
|
||||
prepareResponse((response) -> response.setBody(responseBody).setHeader("Content-Type", "application/json"));
|
||||
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.consumeNextWith((accessLevel) -> assertThat(accessLevel).isEqualTo(AccessLevel.FULL))
|
||||
.expectComplete()
|
||||
.verify();
|
||||
expectRequest((request) -> {
|
||||
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
|
||||
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenNotSpaceDeveloperShouldReturnRestricted() throws Exception {
|
||||
String responseBody = "{\"read_sensitive_data\": false,\"read_basic_data\": true}";
|
||||
prepareResponse((response) -> response.setBody(responseBody).setHeader("Content-Type", "application/json"));
|
||||
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.consumeNextWith((accessLevel) -> assertThat(accessLevel).isEqualTo(AccessLevel.RESTRICTED))
|
||||
.expectComplete()
|
||||
.verify();
|
||||
expectRequest((request) -> {
|
||||
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
|
||||
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenTokenIsNotValidShouldThrowException() throws Exception {
|
||||
prepareResponse((response) -> response.setResponseCode(401));
|
||||
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.consumeErrorWith((throwable) -> {
|
||||
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
|
||||
.isEqualTo(Reason.INVALID_TOKEN);
|
||||
})
|
||||
.verify();
|
||||
expectRequest((request) -> {
|
||||
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
|
||||
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenForbiddenShouldThrowException() throws Exception {
|
||||
prepareResponse((response) -> response.setResponseCode(403));
|
||||
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.consumeErrorWith((throwable) -> {
|
||||
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
|
||||
.isEqualTo(Reason.ACCESS_DENIED);
|
||||
})
|
||||
.verify();
|
||||
expectRequest((request) -> {
|
||||
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
|
||||
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenCloudControllerIsNotReachableThrowsException() throws Exception {
|
||||
prepareResponse((response) -> response.setResponseCode(500));
|
||||
StepVerifier.create(this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.consumeErrorWith((throwable) -> {
|
||||
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE);
|
||||
})
|
||||
.verify();
|
||||
expectRequest((request) -> {
|
||||
assertThat(request.getHeader(HttpHeaders.AUTHORIZATION)).isEqualTo("bearer my-access-token");
|
||||
assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER_PERMISSIONS);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTokenKeysWhenSuccessfulShouldReturnListOfKeysFromUAA() throws Exception {
|
||||
String tokenKeyValue = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
|
||||
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
|
||||
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
|
||||
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
|
||||
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
|
||||
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
|
||||
JwIDAQAB
|
||||
-----END PUBLIC KEY-----""";
|
||||
prepareResponse((response) -> {
|
||||
response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
});
|
||||
String responseBody = "{\"keys\" : [ {\"kid\":\"test-key\",\"value\" : \"" + tokenKeyValue.replace("\n", "\\n")
|
||||
+ "\"} ]}";
|
||||
prepareResponse((response) -> {
|
||||
response.setBody(responseBody);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
});
|
||||
StepVerifier.create(this.securityService.fetchTokenKeys())
|
||||
.consumeNextWith((tokenKeys) -> assertThat(tokenKeys.get("test-key")).isEqualTo(tokenKeyValue))
|
||||
.expectComplete()
|
||||
.verify();
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTokenKeysWhenNoKeysReturnedFromUAA() throws Exception {
|
||||
prepareResponse((response) -> {
|
||||
response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
});
|
||||
String responseBody = "{\"keys\": []}";
|
||||
prepareResponse((response) -> {
|
||||
response.setBody(responseBody);
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
});
|
||||
StepVerifier.create(this.securityService.fetchTokenKeys())
|
||||
.consumeNextWith((tokenKeys) -> assertThat(tokenKeys).isEmpty())
|
||||
.expectComplete()
|
||||
.verify();
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTokenKeysWhenUnsuccessfulShouldThrowException() throws Exception {
|
||||
prepareResponse((response) -> {
|
||||
response.setBody("{\"token_endpoint\":\"/my-uaa.com\"}");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
});
|
||||
prepareResponse((response) -> response.setResponseCode(500));
|
||||
StepVerifier.create(this.securityService.fetchTokenKeys())
|
||||
.consumeErrorWith((throwable) -> assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE))
|
||||
.verify();
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-cloud-controller.com/info"));
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo("/my-uaa.com/token_keys"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() throws Exception {
|
||||
prepareResponse((response) -> {
|
||||
response.setBody("{\"token_endpoint\":\"" + UAA_URL + "\"}");
|
||||
response.setHeader("Content-Type", "application/json");
|
||||
});
|
||||
StepVerifier.create(this.securityService.getUaaUrl())
|
||||
.consumeNextWith((uaaUrl) -> assertThat(uaaUrl).isEqualTo(UAA_URL))
|
||||
.expectComplete()
|
||||
.verify();
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER + "/info"));
|
||||
expectRequestCount(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException() throws Exception {
|
||||
prepareResponse((response) -> response.setResponseCode(500));
|
||||
StepVerifier.create(this.securityService.getUaaUrl()).consumeErrorWith((throwable) -> {
|
||||
assertThat(throwable).isInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) throwable).getReason())
|
||||
.isEqualTo(Reason.SERVICE_UNAVAILABLE);
|
||||
}).verify();
|
||||
expectRequest((request) -> assertThat(request.getPath()).isEqualTo(CLOUD_CONTROLLER + "/info"));
|
||||
}
|
||||
|
||||
private void prepareResponse(Consumer<MockResponse> consumer) {
|
||||
MockResponse response = new MockResponse();
|
||||
consumer.accept(response);
|
||||
this.server.enqueue(response);
|
||||
}
|
||||
|
||||
private void expectRequest(Consumer<RecordedRequest> consumer) throws InterruptedException {
|
||||
consumer.accept(this.server.takeRequest());
|
||||
}
|
||||
|
||||
private void expectRequestCount(int count) {
|
||||
assertThat(count).isEqualTo(this.server.getRequestCount());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.reactive;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import reactor.test.publisher.PublisherProbe;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
|
||||
/**
|
||||
* Tests for {@link ReactiveTokenValidator}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class ReactiveTokenValidatorTests {
|
||||
|
||||
private static final byte[] DOT = ".".getBytes();
|
||||
|
||||
@Mock
|
||||
private ReactiveCloudFoundrySecurityService securityService;
|
||||
|
||||
private ReactiveTokenValidator tokenValidator;
|
||||
|
||||
private static final String VALID_KEY = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
|
||||
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
|
||||
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
|
||||
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
|
||||
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
|
||||
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
|
||||
JwIDAQAB
|
||||
-----END PUBLIC KEY-----""";
|
||||
|
||||
private static final String INVALID_KEY = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK
|
||||
5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa
|
||||
vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0
|
||||
FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC
|
||||
VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M
|
||||
r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s
|
||||
YwIDAQAB
|
||||
-----END PUBLIC KEY-----""";
|
||||
|
||||
private static final Map<String, String> INVALID_KEYS = new ConcurrentHashMap<>();
|
||||
|
||||
private static final Map<String, String> VALID_KEYS = new ConcurrentHashMap<>();
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
VALID_KEYS.put("valid-key", VALID_KEY);
|
||||
INVALID_KEYS.put("invalid-key", INVALID_KEY);
|
||||
this.tokenValidator = new ReactiveTokenValidator(this.securityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenKidValidationFailsTwiceShouldThrowException() throws Exception {
|
||||
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "cachedTokenKeys", VALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"invalid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_KEY_ID);
|
||||
})
|
||||
.verify();
|
||||
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
|
||||
fetchTokenKeys.assertWasSubscribed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenKidValidationSucceedsInTheSecondAttempt() throws Exception {
|
||||
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "cachedTokenKeys", INVALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
|
||||
fetchTokenKeys.assertWasSubscribed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenCacheIsEmptyShouldFetchTokenKeys() throws Exception {
|
||||
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
|
||||
fetchTokenKeys.assertWasSubscribed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenCacheEmptyAndInvalidKeyShouldThrowException() throws Exception {
|
||||
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.of(Mono.just(VALID_KEYS));
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(fetchTokenKeys.mono());
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"invalid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_KEY_ID);
|
||||
})
|
||||
.verify();
|
||||
assertThat(this.tokenValidator).hasFieldOrPropertyWithValue("cachedTokenKeys", VALID_KEYS);
|
||||
fetchTokenKeys.assertWasSubscribed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenCacheValidShouldNotFetchTokenKeys() throws Exception {
|
||||
PublisherProbe<Map<String, String>> fetchTokenKeys = PublisherProbe.empty();
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "cachedTokenKeys", VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.expectComplete()
|
||||
.verify(Duration.ofSeconds(30));
|
||||
fetchTokenKeys.assertWasNotSubscribed();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenSignatureInvalidShouldThrowException() throws Exception {
|
||||
Map<String, String> KEYS = Collections.singletonMap("valid-key", INVALID_KEY);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(KEYS));
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_SIGNATURE);
|
||||
})
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenTokenAlgorithmIsNotRS256ShouldThrowException() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{ \"alg\": \"HS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason())
|
||||
.isEqualTo(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM);
|
||||
})
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenExpiredShouldThrowException() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"jti\": \"0236399c350c47f3ae77e67a75e75e7d\", \"exp\": 1477509977, \"scope\": [\"actuator.read\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.TOKEN_EXPIRED);
|
||||
})
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenIssuerIsNotValidShouldThrowException() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("https://other-uaa.com"));
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\", \"scope\": [\"actuator.read\"]}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_ISSUER);
|
||||
})
|
||||
.verify();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenAudienceIsNotValidShouldThrowException() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(Mono.just(VALID_KEYS));
|
||||
given(this.securityService.getUaaUrl()).willReturn(Mono.just("http://localhost:8080/uaa"));
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}";
|
||||
StepVerifier
|
||||
.create(this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.consumeErrorWith((ex) -> {
|
||||
assertThat(ex).isExactlyInstanceOf(CloudFoundryAuthorizationException.class);
|
||||
assertThat(((CloudFoundryAuthorizationException) ex).getReason()).isEqualTo(Reason.INVALID_AUDIENCE);
|
||||
})
|
||||
.verify();
|
||||
}
|
||||
|
||||
private String getSignedToken(byte[] header, byte[] claims) throws Exception {
|
||||
PrivateKey privateKey = getPrivateKey();
|
||||
Signature signature = Signature.getInstance("SHA256WithRSA");
|
||||
signature.initSign(privateKey);
|
||||
byte[] content = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getEncoder().encode(claims));
|
||||
signature.update(content);
|
||||
byte[] crypto = signature.sign();
|
||||
byte[] token = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getUrlEncoder().encode(claims),
|
||||
Base64.getUrlEncoder().encode(crypto));
|
||||
return new String(token, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private PrivateKey getPrivateKey() throws InvalidKeySpecException, NoSuchAlgorithmException {
|
||||
String signingKey = """
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDSbn2Xa72IOcxu
|
||||
tcd+qQ6ufZ1VDe98EmpwO4VQrTd37U9kZtWU0KqeSkgnyzIWmlbyWOdbB4/v4uJa
|
||||
lGjPQjt9hvd3xOOFXzpj33sWXgMGvGAzopMk64T+7GegOFlDXguA5TZyReM7M51O
|
||||
ycYwpAEsKXS+lxcG0UsxpJum/WjOLyHsMnJVnoScVBlRYZ2BMyEOuap69/H3lT/X
|
||||
pzlYEM6SrAifsaWvL2f1K7HKBt/yDkDOlZy6xmAMsghnslNSV0FvypTZrQOXia8t
|
||||
k6fjA+iN+P0LDZAgKxzn4/B/bV8/6HN/7VZJEdudi/y5qdE7SBnx6QZqCEz/YfqC
|
||||
olujacgnAgMBAAECggEAc9X2tJ/OWWrXqinOg160gkELloJxTi8lAFsDbAGuAwpT
|
||||
JcWl1KF5CmGBjsY/8ElNi2J9GJL1HOwcBhikCVNARD1DhF6RkB13mvquWwWtTMvt
|
||||
eP8JWM19DIc+E+hw2rCuTGngqs7l4vTqpzBTNPtS2eiIJ1IsjsgvSEiAlk/wnW48
|
||||
11cf6SQMQcT3HNTWrS+yLycEuWKb6Khh8RpD9D+i8w2+IspWz5lTP7BrKCUNsLOx
|
||||
6+5T52HcaZ9z3wMnDqfqIKWl3h8M+q+HFQ4EN5BPWYV4fF7EOx7+Qf2fKDFPoTjC
|
||||
VTWzDRNAA1xPqwdF7IdPVOXCdaUJDOhHeXZGaTNSwQKBgQDxb9UiR/Jh1R3muL7I
|
||||
neIt1gXa0O+SK7NWYl4DkArYo7V81ztxI8r+xKEeu5zRZZkpaJHxOnd3VfADascw
|
||||
UfALvxGxN2z42lE6zdhrmxZ3ma+akQFsv7NyXcBT00sdW+xmOiCaAj0cgxNOXiV3
|
||||
sYOwUy3SqUIPO2obpb+KC5ALHwKBgQDfH+NSQ/jn89oVZ3lzUORa+Z+aL1TGsgzs
|
||||
p7IG0MTEYiR9/AExYUwJab0M4PDXhumeoACMfkCFALNVhpch2nXZv7X5445yRgfD
|
||||
ONY4WknecuA0rfCLTruNWnQ3RR+BXmd9jD/5igd9hEIawz3V+jCHvAtzI8/CZIBt
|
||||
AArBs5kp+QKBgQCdxwN1n6baIDemK10iJWtFoPO6h4fH8h8EeMwPb/ZmlLVpnA4Q
|
||||
Zd+mlkDkoJ5eiRKKaPfWuOqRZeuvj/wTq7g/NOIO+bWQ+rrSvuqLh5IrHpgPXmub
|
||||
8bsHJhUlspMH4KagN6ROgOAG3fGj6Qp7KdpxRCpR3KJ66czxvGNrhxre6QKBgB+s
|
||||
MCGiYnfSprd5G8VhyziazKwfYeJerfT+DQhopDXYVKPJnQW8cQW5C8wDNkzx6sHI
|
||||
pqtK1K/MnKhcVaHJmAcT7qoNQlA4Xqu4qrgPIQNBvU/dDRNJVthG6c5aspEzrG8m
|
||||
9IHgtRV9K8EOy/1O6YqrB9kNUVWf3JccdWpvqyNJAoGAORzJiQCOk4egbdcozDTo
|
||||
4Tg4qk/03qpTy5k64DxkX1nJHu8V/hsKwq9Af7Fj/iHy2Av54BLPlBaGPwMi2bzB
|
||||
gYjmUomvx/fqOTQks9Rc4PIMB43p6Rdj0sh+52SKPDR2eHbwsmpuQUXnAs20BPPI
|
||||
J/OOn5zOs8yf26os0q3+JUM=
|
||||
-----END PRIVATE KEY-----""";
|
||||
String privateKey = signingKey.replace("-----BEGIN PRIVATE KEY-----\n", "");
|
||||
privateKey = privateKey.replace("-----END PRIVATE KEY-----", "");
|
||||
privateKey = privateKey.replace("\n", "");
|
||||
byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(privateKey);
|
||||
PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(pkcs8EncodedBytes);
|
||||
KeyFactory keyFactory = KeyFactory.getInstance("RSA");
|
||||
return keyFactory.generatePrivate(keySpec);
|
||||
}
|
||||
|
||||
private byte[] dotConcat(byte[]... bytes) throws IOException {
|
||||
ByteArrayOutputStream result = new ByteArrayOutputStream();
|
||||
for (int i = 0; i < bytes.length; i++) {
|
||||
if (i > 0) {
|
||||
StreamUtils.copy(DOT, result);
|
||||
}
|
||||
StreamUtils.copy(bytes[i], result);
|
||||
}
|
||||
return result.toByteArray();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
import jakarta.servlet.Filter;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperation;
|
||||
import org.springframework.boot.actuate.endpoint.web.WebOperationRequestPredicate;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.test.context.assertj.AssertableWebApplicationContext;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.security.config.BeanIds;
|
||||
import org.springframework.security.web.FilterChainProxy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.assertj.MockMvcTester;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.filter.CompositeFilter;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
|
||||
import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryActuatorAutoConfiguration}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryActuatorAutoConfigurationTests {
|
||||
|
||||
private static final String V3_JSON = ApiVersion.V3.getProducedMimeType().toString();
|
||||
|
||||
private static final String BASE_PATH = "/cloudfoundryapplication";
|
||||
|
||||
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
|
||||
.withConfiguration(AutoConfigurations.of(SecurityAutoConfiguration.class, WebMvcAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
|
||||
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
|
||||
RestTemplateAutoConfiguration.class, ManagementContextAutoConfiguration.class,
|
||||
ServletManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class,
|
||||
WebEndpointAutoConfiguration.class, CloudFoundryActuatorAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActive() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
EndpointMapping endpointMapping = (EndpointMapping) ReflectionTestUtils.getField(handlerMapping,
|
||||
"endpointMapping");
|
||||
assertThat(endpointMapping.getPath()).isEqualTo("/cloudfoundryapplication");
|
||||
CorsConfiguration corsConfiguration = (CorsConfiguration) ReflectionTestUtils.getField(handlerMapping,
|
||||
"corsConfiguration");
|
||||
assertThat(corsConfiguration.getAllowedOrigins()).contains("*");
|
||||
assertThat(corsConfiguration.getAllowedMethods())
|
||||
.containsAll(Arrays.asList(HttpMethod.GET.name(), HttpMethod.POST.name()));
|
||||
assertThat(corsConfiguration.getAllowedHeaders())
|
||||
.containsAll(Arrays.asList("Authorization", "X-Cf-App-Instance", "Content-Type"));
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudfoundryapplicationProducesActuatorMediaType() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
MockMvcTester mvc = MockMvcTester.from(context);
|
||||
assertThat(mvc.get().uri("/cloudfoundryapplication")).hasHeader("Content-Type", V3_JSON);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActiveSetsApplicationId() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Object interceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
|
||||
String applicationId = (String) ReflectionTestUtils.getField(interceptor, "applicationId");
|
||||
assertThat(applicationId).isEqualTo("my-app-id");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActiveSetsCloudControllerUrl() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Object interceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
|
||||
Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor,
|
||||
"cloudFoundrySecurityService");
|
||||
String cloudControllerUrl = (String) ReflectionTestUtils.getField(interceptorSecurityService,
|
||||
"cloudControllerUrl");
|
||||
assertThat(cloudControllerUrl).isEqualTo("https://my-cloud-controller.com");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipSslValidation() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com",
|
||||
"management.cloudfoundry.skip-ssl-validation:true")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Object interceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
|
||||
Object interceptorSecurityService = ReflectionTestUtils.getField(interceptor,
|
||||
"cloudFoundrySecurityService");
|
||||
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils.getField(interceptorSecurityService,
|
||||
"restTemplate");
|
||||
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SkipSslVerificationHttpRequestFactory.class);
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformActiveAndCloudControllerUrlNotPresent() {
|
||||
this.contextRunner.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Object securityInterceptor = ReflectionTestUtils.getField(handlerMapping, "securityInterceptor");
|
||||
Object interceptorSecurityService = ReflectionTestUtils.getField(securityInterceptor,
|
||||
"cloudFoundrySecurityService");
|
||||
assertThat(interceptorSecurityService).isNull();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPathsPermittedBySpringSecurity() {
|
||||
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
|
||||
.run((context) -> {
|
||||
SecurityFilterChain chain = getSecurityFilterChain(context);
|
||||
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||
testCloudFoundrySecurity(request, BASE_PATH, chain);
|
||||
testCloudFoundrySecurity(request, BASE_PATH + "/", chain);
|
||||
testCloudFoundrySecurity(request, BASE_PATH + "/test", chain);
|
||||
testCloudFoundrySecurity(request, BASE_PATH + "/test/a", chain);
|
||||
request.setServletPath(BASE_PATH + "/other-path");
|
||||
request.setRequestURI(BASE_PATH + "/other-path");
|
||||
assertThat(chain.matches(request)).isFalse();
|
||||
request.setServletPath("/some-other-path");
|
||||
request.setRequestURI("/some-other-path");
|
||||
assertThat(chain.matches(request)).isFalse();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPathsPermittedWithCsrfBySpringSecurity() {
|
||||
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id")
|
||||
.run((context) -> {
|
||||
MockMvc mvc = MockMvcBuilders.webAppContextSetup(context).apply(springSecurity()).build();
|
||||
mvc.perform(post(BASE_PATH + "/test?name=test").contentType(MediaType.APPLICATION_JSON)
|
||||
.with(csrf().useInvalidToken())).andExpect(status().isServiceUnavailable());
|
||||
// If CSRF fails we'll get a 403, if it works we get service unavailable
|
||||
// because of "Cloud controller URL is not available"
|
||||
});
|
||||
}
|
||||
|
||||
private SecurityFilterChain getSecurityFilterChain(AssertableWebApplicationContext context) {
|
||||
Filter springSecurityFilterChain = context.getBean(BeanIds.SPRING_SECURITY_FILTER_CHAIN, Filter.class);
|
||||
FilterChainProxy filterChainProxy = getFilterChainProxy(springSecurityFilterChain);
|
||||
SecurityFilterChain securityFilterChain = filterChainProxy.getFilterChains().get(0);
|
||||
return securityFilterChain;
|
||||
}
|
||||
|
||||
private FilterChainProxy getFilterChainProxy(Filter filter) {
|
||||
if (filter instanceof FilterChainProxy filterChainProxy) {
|
||||
return filterChainProxy;
|
||||
}
|
||||
if (filter instanceof CompositeFilter) {
|
||||
List<?> filters = (List<?>) ReflectionTestUtils.getField(filter, "filters");
|
||||
return (FilterChainProxy) filters.stream()
|
||||
.filter(FilterChainProxy.class::isInstance)
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
}
|
||||
throw new IllegalStateException("No FilterChainProxy found");
|
||||
}
|
||||
|
||||
private static void testCloudFoundrySecurity(MockHttpServletRequest request, String requestUri,
|
||||
SecurityFilterChain chain) {
|
||||
request.setRequestURI(requestUri);
|
||||
assertThat(chain.matches(request)).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryPlatformInactive() {
|
||||
this.contextRunner.withPropertyValues()
|
||||
.run((context) -> assertThat(context.containsBean("cloudFoundryWebEndpointServletHandlerMapping"))
|
||||
.isFalse());
|
||||
}
|
||||
|
||||
@Test
|
||||
void cloudFoundryManagementEndpointsDisabled() {
|
||||
this.contextRunner.withPropertyValues("VCAP_APPLICATION=---", "management.cloudfoundry.enabled:false")
|
||||
.run((context) -> assertThat(context.containsBean("cloudFoundryEndpointHandlerMapping")).isFalse());
|
||||
}
|
||||
|
||||
@Test
|
||||
void allEndpointsAvailableUnderCloudFoundryWithoutExposeAllOnWeb() {
|
||||
this.contextRunner.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
|
||||
assertThat(endpoints.stream()
|
||||
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
|
||||
.findFirst()).isNotEmpty();
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointPathCustomizationIsNotApplied() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com",
|
||||
"management.endpoints.web.path-mapping.test=custom")
|
||||
.withBean(TestEndpoint.class, TestEndpoint::new)
|
||||
.run((context) -> {
|
||||
CloudFoundryWebEndpointServletHandlerMapping handlerMapping = getHandlerMapping(context);
|
||||
Collection<ExposableWebEndpoint> endpoints = handlerMapping.getEndpoints();
|
||||
ExposableWebEndpoint endpoint = endpoints.stream()
|
||||
.filter((candidate) -> EndpointId.of("test").equals(candidate.getEndpointId()))
|
||||
.findFirst()
|
||||
.get();
|
||||
Collection<WebOperation> operations = endpoint.getOperations();
|
||||
assertThat(operations).hasSize(2);
|
||||
assertThat(operations.iterator().next().getRequestPredicate().getPath()).isEqualTo("test");
|
||||
});
|
||||
}
|
||||
|
||||
@Test
|
||||
void healthEndpointInvokerShouldBeCloudFoundryWebExtension() {
|
||||
this.contextRunner
|
||||
.withPropertyValues("VCAP_APPLICATION:---", "vcap.application.application_id:my-app-id",
|
||||
"vcap.application.cf_api:https://my-cloud-controller.com")
|
||||
.withConfiguration(AutoConfigurations.of(HealthContributorAutoConfiguration.class,
|
||||
HealthEndpointAutoConfiguration.class))
|
||||
.run((context) -> {
|
||||
Collection<ExposableWebEndpoint> endpoints = context
|
||||
.getBean("cloudFoundryWebEndpointServletHandlerMapping",
|
||||
CloudFoundryWebEndpointServletHandlerMapping.class)
|
||||
.getEndpoints();
|
||||
ExposableWebEndpoint endpoint = endpoints.iterator().next();
|
||||
assertThat(endpoint.getOperations()).hasSize(2);
|
||||
WebOperation webOperation = findOperationWithRequestPath(endpoint, "health");
|
||||
assertThat(webOperation).extracting("invoker.target")
|
||||
.isInstanceOf(CloudFoundryHealthEndpointWebExtension.class);
|
||||
});
|
||||
}
|
||||
|
||||
private CloudFoundryWebEndpointServletHandlerMapping getHandlerMapping(ApplicationContext context) {
|
||||
return context.getBean("cloudFoundryWebEndpointServletHandlerMapping",
|
||||
CloudFoundryWebEndpointServletHandlerMapping.class);
|
||||
}
|
||||
|
||||
private WebOperation findOperationWithRequestPath(ExposableWebEndpoint endpoint, String requestPath) {
|
||||
for (WebOperation operation : endpoint.getOperations()) {
|
||||
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
|
||||
if (predicate.getPath().equals(requestPath) && predicate.getProduces().contains(V3_JSON)) {
|
||||
return operation;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException(
|
||||
"No operation found with request path " + requestPath + " from " + endpoint.getOperations());
|
||||
}
|
||||
|
||||
@Endpoint(id = "test")
|
||||
static class TestEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
String hello() {
|
||||
return "hello world";
|
||||
}
|
||||
|
||||
@WriteOperation
|
||||
void update(String name) {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthContributorAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.actuate.endpoint.ApiVersion;
|
||||
import org.springframework.boot.actuate.health.CompositeHealth;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthComponent;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryHealthEndpointWebExtension}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryHealthEndpointWebExtensionTests {
|
||||
|
||||
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
|
||||
.withPropertyValues("VCAP_APPLICATION={}")
|
||||
.withConfiguration(AutoConfigurations.of(SecurityAutoConfiguration.class, WebMvcAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
|
||||
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
|
||||
RestTemplateAutoConfiguration.class, ManagementContextAutoConfiguration.class,
|
||||
ServletManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class,
|
||||
WebEndpointAutoConfiguration.class, HealthContributorAutoConfiguration.class,
|
||||
HealthEndpointAutoConfiguration.class, CloudFoundryActuatorAutoConfiguration.class))
|
||||
.withUserConfiguration(TestHealthIndicator.class);
|
||||
|
||||
@Test
|
||||
void healthComponentsAlwaysPresent() {
|
||||
this.contextRunner.run((context) -> {
|
||||
CloudFoundryHealthEndpointWebExtension extension = context
|
||||
.getBean(CloudFoundryHealthEndpointWebExtension.class);
|
||||
HealthComponent body = extension.health(ApiVersion.V3).getBody();
|
||||
HealthComponent health = ((CompositeHealth) body).getComponents().entrySet().iterator().next().getValue();
|
||||
assertThat(((Health) health).getDetails()).containsEntry("spring", "boot");
|
||||
});
|
||||
}
|
||||
|
||||
private static final class TestHealthIndicator implements HealthIndicator {
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return Health.up().withDetail("spring", "boot").build();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.info.InfoContributorAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.info.InfoEndpointAutoConfiguration;
|
||||
import org.springframework.boot.actuate.autoconfigure.web.server.ManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.AutoConfigurations;
|
||||
import org.springframework.boot.autoconfigure.context.PropertyPlaceholderAutoConfiguration;
|
||||
import org.springframework.boot.autoconfigure.info.ProjectInfoAutoConfiguration;
|
||||
import org.springframework.boot.http.converter.autoconfigure.HttpMessageConvertersAutoConfiguration;
|
||||
import org.springframework.boot.jackson.autoconfigure.JacksonAutoConfiguration;
|
||||
import org.springframework.boot.restclient.autoconfigure.RestTemplateAutoConfiguration;
|
||||
import org.springframework.boot.security.autoconfigure.servlet.SecurityAutoConfiguration;
|
||||
import org.springframework.boot.servlet.actuate.autoconfigure.ServletManagementContextAutoConfiguration;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.boot.testsupport.classpath.resources.WithResource;
|
||||
import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration;
|
||||
import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryInfoEndpointWebExtension}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryInfoEndpointWebExtensionTests {
|
||||
|
||||
private final WebApplicationContextRunner contextRunner = new WebApplicationContextRunner()
|
||||
.withPropertyValues("VCAP_APPLICATION={}")
|
||||
.withConfiguration(AutoConfigurations.of(SecurityAutoConfiguration.class, WebMvcAutoConfiguration.class,
|
||||
JacksonAutoConfiguration.class, DispatcherServletAutoConfiguration.class,
|
||||
HttpMessageConvertersAutoConfiguration.class, PropertyPlaceholderAutoConfiguration.class,
|
||||
RestTemplateAutoConfiguration.class, ManagementContextAutoConfiguration.class,
|
||||
ServletManagementContextAutoConfiguration.class, EndpointAutoConfiguration.class,
|
||||
WebEndpointAutoConfiguration.class, ProjectInfoAutoConfiguration.class,
|
||||
InfoContributorAutoConfiguration.class, InfoEndpointAutoConfiguration.class,
|
||||
HealthEndpointAutoConfiguration.class, CloudFoundryActuatorAutoConfiguration.class));
|
||||
|
||||
@Test
|
||||
@WithResource(name = "git.properties", content = """
|
||||
#Generated by Git-Commit-Id-Plugin
|
||||
#Thu May 23 09:26:42 BST 2013
|
||||
git.commit.id.abbrev=e02a4f3
|
||||
git.commit.user.email=dsyer@vmware.com
|
||||
git.commit.message.full=Update Spring
|
||||
git.commit.id=e02a4f3b6f452cdbf6dd311f1362679eb4c31ced
|
||||
git.commit.message.short=Update Spring
|
||||
git.commit.user.name=Dave Syer
|
||||
git.build.user.name=Dave Syer
|
||||
git.build.user.email=dsyer@vmware.com
|
||||
git.branch=develop
|
||||
git.commit.time=2013-04-24T08\\:42\\:13+0100
|
||||
git.build.time=2013-05-23T09\\:26\\:42+0100
|
||||
""")
|
||||
@SuppressWarnings("unchecked")
|
||||
void gitFullDetailsAlwaysPresent() {
|
||||
this.contextRunner.run((context) -> {
|
||||
CloudFoundryInfoEndpointWebExtension extension = context
|
||||
.getBean(CloudFoundryInfoEndpointWebExtension.class);
|
||||
Map<String, Object> git = (Map<String, Object>) extension.info().get("git");
|
||||
Map<String, Object> commit = (Map<String, Object>) git.get("commit");
|
||||
assertThat(commit).hasSize(4);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.Collection;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.function.BiConsumer;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.ReadOperation;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.Selector;
|
||||
import org.springframework.boot.actuate.endpoint.annotation.WriteOperation;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.invoke.convert.ConversionServiceParameterValueMapper;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
|
||||
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
|
||||
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
|
||||
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
|
||||
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
|
||||
import org.springframework.boot.web.server.servlet.context.AnnotationConfigServletWebServerApplicationContext;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.core.convert.support.DefaultConversionService;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.reactive.server.WebTestClient;
|
||||
import org.springframework.web.cors.CorsConfiguration;
|
||||
import org.springframework.web.servlet.DispatcherServlet;
|
||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
||||
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.willThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
/**
|
||||
* Integration tests for web endpoints exposed using Spring MVC on CloudFoundry.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
class CloudFoundryMvcWebEndpointIntegrationTests {
|
||||
|
||||
private final TokenValidator tokenValidator = mock(TokenValidator.class);
|
||||
|
||||
private final CloudFoundrySecurityService securityService = mock(CloudFoundrySecurityService.class);
|
||||
|
||||
@Test
|
||||
void operationWithSecurityInterceptorForbidden() {
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
|
||||
load(TestEndpointConfiguration.class,
|
||||
(client) -> client.get()
|
||||
.uri("/cfApplication/test")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isEqualTo(HttpStatus.FORBIDDEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void operationWithSecurityInterceptorSuccess() {
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
|
||||
load(TestEndpointConfiguration.class,
|
||||
(client) -> client.get()
|
||||
.uri("/cfApplication/test")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isEqualTo(HttpStatus.OK));
|
||||
}
|
||||
|
||||
@Test
|
||||
void responseToOptionsRequestIncludesCorsHeaders() {
|
||||
load(TestEndpointConfiguration.class,
|
||||
(client) -> client.options()
|
||||
.uri("/cfApplication/test")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Access-Control-Request-Method", "POST")
|
||||
.header("Origin", "https://example.com")
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectHeader()
|
||||
.valueEquals("Access-Control-Allow-Origin", "https://example.com")
|
||||
.expectHeader()
|
||||
.valueEquals("Access-Control-Allow-Methods", "GET,POST"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksToOtherEndpointsWithFullAccess() {
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.FULL);
|
||||
load(TestEndpointConfiguration.class,
|
||||
(client) -> client.get()
|
||||
.uri("/cfApplication")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody()
|
||||
.jsonPath("_links.length()")
|
||||
.isEqualTo(5)
|
||||
.jsonPath("_links.self.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.self.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.info.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.info.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.env.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.env.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.test.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.test.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.test-part.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.test-part.templated")
|
||||
.isEqualTo(true));
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksToOtherEndpointsForbidden() {
|
||||
CloudFoundryAuthorizationException exception = new CloudFoundryAuthorizationException(Reason.INVALID_TOKEN,
|
||||
"invalid-token");
|
||||
willThrow(exception).given(this.tokenValidator).validate(any());
|
||||
load(TestEndpointConfiguration.class,
|
||||
(client) -> client.get()
|
||||
.uri("/cfApplication")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isUnauthorized());
|
||||
}
|
||||
|
||||
@Test
|
||||
void linksToOtherEndpointsWithRestrictedAccess() {
|
||||
given(this.securityService.getAccessLevel(any(), eq("app-id"))).willReturn(AccessLevel.RESTRICTED);
|
||||
load(TestEndpointConfiguration.class,
|
||||
(client) -> client.get()
|
||||
.uri("/cfApplication")
|
||||
.accept(MediaType.APPLICATION_JSON)
|
||||
.header("Authorization", "bearer " + mockAccessToken())
|
||||
.exchange()
|
||||
.expectStatus()
|
||||
.isOk()
|
||||
.expectBody()
|
||||
.jsonPath("_links.length()")
|
||||
.isEqualTo(2)
|
||||
.jsonPath("_links.self.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.self.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.info.href")
|
||||
.isNotEmpty()
|
||||
.jsonPath("_links.info.templated")
|
||||
.isEqualTo(false)
|
||||
.jsonPath("_links.env")
|
||||
.doesNotExist()
|
||||
.jsonPath("_links.test")
|
||||
.doesNotExist()
|
||||
.jsonPath("_links.test-part")
|
||||
.doesNotExist());
|
||||
}
|
||||
|
||||
private void load(Class<?> configuration, Consumer<WebTestClient> clientConsumer) {
|
||||
BiConsumer<ApplicationContext, WebTestClient> consumer = (context, client) -> clientConsumer.accept(client);
|
||||
new WebApplicationContextRunner(AnnotationConfigServletWebServerApplicationContext::new)
|
||||
.withUserConfiguration(configuration, CloudFoundryMvcConfiguration.class)
|
||||
.withBean(TokenValidator.class, () -> this.tokenValidator)
|
||||
.withBean(CloudFoundrySecurityService.class, () -> this.securityService)
|
||||
.run((context) -> consumer.accept(context, WebTestClient.bindToServer()
|
||||
.baseUrl("http://localhost:" + getPort(
|
||||
(AnnotationConfigServletWebServerApplicationContext) context.getSourceApplicationContext()))
|
||||
.responseTimeout(Duration.ofMinutes(5))
|
||||
.build()));
|
||||
}
|
||||
|
||||
private int getPort(AnnotationConfigServletWebServerApplicationContext context) {
|
||||
return context.getWebServer().getPort();
|
||||
}
|
||||
|
||||
private String mockAccessToken() {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
|
||||
+ Base64.getEncoder().encodeToString("signature".getBytes());
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@EnableWebMvc
|
||||
static class CloudFoundryMvcConfiguration {
|
||||
|
||||
@Bean
|
||||
CloudFoundrySecurityInterceptor interceptor(TokenValidator tokenValidator,
|
||||
CloudFoundrySecurityService securityService) {
|
||||
return new CloudFoundrySecurityInterceptor(tokenValidator, securityService, "app-id");
|
||||
}
|
||||
|
||||
@Bean
|
||||
EndpointMediaTypes EndpointMediaTypes() {
|
||||
return new EndpointMediaTypes(Collections.singletonList("application/json"),
|
||||
Collections.singletonList("application/json"));
|
||||
}
|
||||
|
||||
@Bean
|
||||
CloudFoundryWebEndpointServletHandlerMapping cloudFoundryWebEndpointServletHandlerMapping(
|
||||
WebEndpointDiscoverer webEndpointDiscoverer, EndpointMediaTypes endpointMediaTypes,
|
||||
CloudFoundrySecurityInterceptor interceptor) {
|
||||
CorsConfiguration corsConfiguration = new CorsConfiguration();
|
||||
corsConfiguration.setAllowedOrigins(Arrays.asList("https://example.com"));
|
||||
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST"));
|
||||
Collection<ExposableWebEndpoint> webEndpoints = webEndpointDiscoverer.getEndpoints();
|
||||
List<ExposableEndpoint<?>> allEndpoints = new ArrayList<>(webEndpoints);
|
||||
return new CloudFoundryWebEndpointServletHandlerMapping(new EndpointMapping("/cfApplication"), webEndpoints,
|
||||
endpointMediaTypes, corsConfiguration, interceptor, allEndpoints);
|
||||
}
|
||||
|
||||
@Bean
|
||||
WebEndpointDiscoverer webEndpointDiscoverer(ApplicationContext applicationContext,
|
||||
EndpointMediaTypes endpointMediaTypes) {
|
||||
ParameterValueMapper parameterMapper = new ConversionServiceParameterValueMapper(
|
||||
DefaultConversionService.getSharedInstance());
|
||||
return new WebEndpointDiscoverer(applicationContext, parameterMapper, endpointMediaTypes, null, null,
|
||||
Collections.emptyList(), Collections.emptyList(), Collections.emptyList());
|
||||
}
|
||||
|
||||
@Bean
|
||||
EndpointDelegate endpointDelegate() {
|
||||
return mock(EndpointDelegate.class);
|
||||
}
|
||||
|
||||
@Bean
|
||||
TomcatServletWebServerFactory tomcat() {
|
||||
return new TomcatServletWebServerFactory(0);
|
||||
}
|
||||
|
||||
@Bean
|
||||
DispatcherServlet dispatcherServlet() {
|
||||
return new DispatcherServlet();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "test")
|
||||
static class TestEndpoint {
|
||||
|
||||
private final EndpointDelegate endpointDelegate;
|
||||
|
||||
TestEndpoint(EndpointDelegate endpointDelegate) {
|
||||
this.endpointDelegate = endpointDelegate;
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readAll() {
|
||||
return Collections.singletonMap("All", true);
|
||||
}
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readPart(@Selector String part) {
|
||||
return Collections.singletonMap("part", part);
|
||||
}
|
||||
|
||||
@WriteOperation
|
||||
void write(String foo, String bar) {
|
||||
this.endpointDelegate.write(foo, bar);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "env")
|
||||
static class TestEnvEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readAll() {
|
||||
return Collections.singletonMap("All", true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Endpoint(id = "info")
|
||||
static class TestInfoEndpoint {
|
||||
|
||||
@ReadOperation
|
||||
Map<String, Object> readAll() {
|
||||
return Collections.singletonMap("All", true);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@Configuration(proxyBeanMethods = false)
|
||||
@Import(CloudFoundryMvcConfiguration.class)
|
||||
static class TestEndpointConfiguration {
|
||||
|
||||
@Bean
|
||||
TestEndpoint testEndpoint(EndpointDelegate endpointDelegate) {
|
||||
return new TestEndpoint(endpointDelegate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestInfoEndpoint testInfoEnvEndpoint() {
|
||||
return new TestInfoEndpoint();
|
||||
}
|
||||
|
||||
@Bean
|
||||
TestEnvEndpoint testEnvEndpoint() {
|
||||
return new TestEnvEndpoint();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
interface EndpointDelegate {
|
||||
|
||||
void write();
|
||||
|
||||
void write(String foo, String bar);
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Base64;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import org.springframework.boot.actuate.endpoint.EndpointId;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.SecurityResponse;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.assertArg;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.then;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundrySecurityInterceptor}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class CloudFoundrySecurityInterceptorTests {
|
||||
|
||||
@Mock
|
||||
private TokenValidator tokenValidator;
|
||||
|
||||
@Mock
|
||||
private CloudFoundrySecurityService securityService;
|
||||
|
||||
private CloudFoundrySecurityInterceptor interceptor;
|
||||
|
||||
private MockHttpServletRequest request;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, "my-app-id");
|
||||
this.request = new MockHttpServletRequest();
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenRequestIsPreFlightShouldReturnTrue() {
|
||||
this.request.setMethod("OPTIONS");
|
||||
this.request.addHeader(HttpHeaders.ORIGIN, "https://example.com");
|
||||
this.request.addHeader(HttpHeaders.ACCESS_CONTROL_REQUEST_METHOD, "GET");
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenTokenIsMissingShouldReturnFalse() {
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenTokenIsNotBearerShouldReturnFalse() {
|
||||
this.request.addHeader("Authorization", mockAccessToken());
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(Reason.MISSING_AUTHORIZATION.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenApplicationIdIsNullShouldReturnFalse() {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, this.securityService, null);
|
||||
this.request.addHeader("Authorization", "bearer " + mockAccessToken());
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenCloudFoundrySecurityServiceIsNullShouldReturnFalse() {
|
||||
this.interceptor = new CloudFoundrySecurityInterceptor(this.tokenValidator, null, "my-app-id");
|
||||
this.request.addHeader("Authorization", "bearer " + mockAccessToken());
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(Reason.SERVICE_UNAVAILABLE.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleWhenAccessIsNotAllowedShouldReturnFalse() {
|
||||
String accessToken = mockAccessToken();
|
||||
this.request.addHeader("Authorization", "bearer " + accessToken);
|
||||
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(AccessLevel.RESTRICTED);
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
assertThat(response.getStatus()).isEqualTo(Reason.ACCESS_DENIED.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleSuccessfulWithFullAccess() {
|
||||
String accessToken = mockAccessToken();
|
||||
this.request.addHeader("Authorization", "Bearer " + accessToken);
|
||||
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(AccessLevel.FULL);
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("test"));
|
||||
then(this.tokenValidator).should().validate(assertArg((token) -> assertThat(token).hasToString(accessToken)));
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(this.request.getAttribute("cloudFoundryAccessLevel")).isEqualTo(AccessLevel.FULL);
|
||||
}
|
||||
|
||||
@Test
|
||||
void preHandleSuccessfulWithRestrictedAccess() {
|
||||
String accessToken = mockAccessToken();
|
||||
this.request.addHeader("Authorization", "Bearer " + accessToken);
|
||||
given(this.securityService.getAccessLevel(accessToken, "my-app-id")).willReturn(AccessLevel.RESTRICTED);
|
||||
SecurityResponse response = this.interceptor.preHandle(this.request, EndpointId.of("info"));
|
||||
then(this.tokenValidator).should().validate(assertArg((token) -> assertThat(token).hasToString(accessToken)));
|
||||
assertThat(response.getStatus()).isEqualTo(HttpStatus.OK);
|
||||
assertThat(this.request.getAttribute("cloudFoundryAccessLevel")).isEqualTo(AccessLevel.RESTRICTED);
|
||||
}
|
||||
|
||||
private String mockAccessToken() {
|
||||
return "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJ0b3B0YWwu"
|
||||
+ "Y29tIiwiZXhwIjoxNDI2NDIwODAwLCJhd2Vzb21lIjp0cnVlfQ."
|
||||
+ Base64.getEncoder().encodeToString("signature".getBytes());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.AccessLevel;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.restclient.RestTemplateBuilder;
|
||||
import org.springframework.boot.restclient.test.MockServerRestTemplateCustomizer;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.test.web.client.MockRestServiceServer;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
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
|
||||
*/
|
||||
class CloudFoundrySecurityServiceTests {
|
||||
|
||||
private static final String CLOUD_CONTROLLER = "https://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 = "https://my-uaa.com";
|
||||
|
||||
private CloudFoundrySecurityService securityService;
|
||||
|
||||
private MockRestServiceServer server;
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
MockServerRestTemplateCustomizer mockServerCustomizer = new MockServerRestTemplateCustomizer();
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder(mockServerCustomizer);
|
||||
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
|
||||
this.server = mockServerCustomizer.getServer();
|
||||
}
|
||||
|
||||
@Test
|
||||
void skipSslValidationWhenTrue() {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder();
|
||||
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER, true);
|
||||
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils.getField(this.securityService, "restTemplate");
|
||||
assertThat(restTemplate.getRequestFactory()).isInstanceOf(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void doNotSkipSslValidationWhenFalse() {
|
||||
RestTemplateBuilder builder = new RestTemplateBuilder();
|
||||
this.securityService = new CloudFoundrySecurityService(builder, CLOUD_CONTROLLER, false);
|
||||
RestTemplate restTemplate = (RestTemplate) ReflectionTestUtils.getField(this.securityService, "restTemplate");
|
||||
assertThat(restTemplate.getRequestFactory()).isNotInstanceOf(SkipSslVerificationHttpRequestFactory.class);
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenSpaceDeveloperShouldReturnFull() {
|
||||
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
|
||||
void getAccessLevelWhenNotSpaceDeveloperShouldReturnRestricted() {
|
||||
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
|
||||
void getAccessLevelWhenTokenIsNotValidShouldThrowException() {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withUnauthorizedRequest());
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_TOKEN));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenForbiddenShouldThrowException() {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withStatus(HttpStatus.FORBIDDEN));
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.satisfies(reasonRequirement(Reason.ACCESS_DENIED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getAccessLevelWhenCloudControllerIsNotReachableThrowsException() {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER_PERMISSIONS))
|
||||
.andExpect(header("Authorization", "bearer my-access-token"))
|
||||
.andRespond(withServerError());
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> this.securityService.getAccessLevel("my-access-token", "my-app-id"))
|
||||
.satisfies(reasonRequirement(Reason.SERVICE_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTokenKeysWhenSuccessfulShouldReturnListOfKeysFromUAA() {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
|
||||
.andRespond(withSuccess("{\"token_endpoint\":\"https://my-uaa.com\"}", MediaType.APPLICATION_JSON));
|
||||
String tokenKeyValue = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
|
||||
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
|
||||
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
|
||||
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
|
||||
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
|
||||
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
|
||||
JwIDAQAB
|
||||
-----END PUBLIC KEY-----""";
|
||||
String responseBody = "{\"keys\" : [ {\"kid\":\"test-key\",\"value\" : \"" + tokenKeyValue.replace("\n", "\\n")
|
||||
+ "\"} ]}";
|
||||
this.server.expect(requestTo(UAA_URL + "/token_keys"))
|
||||
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
|
||||
Map<String, String> tokenKeys = this.securityService.fetchTokenKeys();
|
||||
this.server.verify();
|
||||
assertThat(tokenKeys).containsEntry("test-key", tokenKeyValue);
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTokenKeysWhenNoKeysReturnedFromUAA() {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info"))
|
||||
.andRespond(withSuccess("{\"token_endpoint\":\"" + UAA_URL + "\"}", MediaType.APPLICATION_JSON));
|
||||
String responseBody = "{\"keys\": []}";
|
||||
this.server.expect(requestTo(UAA_URL + "/token_keys"))
|
||||
.andRespond(withSuccess(responseBody, MediaType.APPLICATION_JSON));
|
||||
Map<String, String> tokenKeys = this.securityService.fetchTokenKeys();
|
||||
this.server.verify();
|
||||
assertThat(tokenKeys).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void fetchTokenKeysWhenUnsuccessfulShouldThrowException() {
|
||||
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());
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> this.securityService.fetchTokenKeys())
|
||||
.satisfies(reasonRequirement(Reason.SERVICE_UNAVAILABLE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void getUaaUrlShouldCallCloudControllerInfoOnlyOnce() {
|
||||
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
|
||||
void getUaaUrlWhenCloudControllerUrlIsNotReachableShouldThrowException() {
|
||||
this.server.expect(requestTo(CLOUD_CONTROLLER + "/info")).andRespond(withServerError());
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(() -> this.securityService.getUaaUrl())
|
||||
.satisfies(reasonRequirement(Reason.SERVICE_UNAVAILABLE));
|
||||
}
|
||||
|
||||
private Consumer<CloudFoundryAuthorizationException> reasonRequirement(Reason reason) {
|
||||
return (ex) -> assertThat(ex.getReason()).isEqualTo(reason);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.aot.hint.RuntimeHints;
|
||||
import org.springframework.aot.hint.predicate.RuntimeHintsPredicates;
|
||||
import org.springframework.boot.actuate.endpoint.web.Link;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryWebEndpointServletHandlerMapping.CloudFoundryLinksHandler;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.servlet.CloudFoundryWebEndpointServletHandlerMapping.CloudFoundryWebEndpointServletHandlerMappingRuntimeHints;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
/**
|
||||
* Tests for {@link CloudFoundryWebEndpointServletHandlerMapping}.
|
||||
*
|
||||
* @author Moritz Halbritter
|
||||
*/
|
||||
class CloudFoundryWebEndpointServletHandlerMappingTests {
|
||||
|
||||
@Test
|
||||
void shouldRegisterHints() {
|
||||
RuntimeHints runtimeHints = new RuntimeHints();
|
||||
new CloudFoundryWebEndpointServletHandlerMappingRuntimeHints().registerHints(runtimeHints,
|
||||
getClass().getClassLoader());
|
||||
assertThat(RuntimeHintsPredicates.reflection().onMethodInvocation(CloudFoundryLinksHandler.class, "links"))
|
||||
.accepts(runtimeHints);
|
||||
assertThat(RuntimeHintsPredicates.reflection().onType(Link.class)).accepts(runtimeHints);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import javax.net.ssl.SSLHandshakeException;
|
||||
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import org.springframework.boot.testsupport.classpath.resources.WithPackageResources;
|
||||
import org.springframework.boot.testsupport.web.servlet.ExampleServlet;
|
||||
import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory;
|
||||
import org.springframework.boot.web.server.Ssl;
|
||||
import org.springframework.boot.web.server.WebServer;
|
||||
import org.springframework.boot.web.servlet.ServletRegistrationBean;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.client.ResourceAccessException;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
|
||||
/**
|
||||
* Test for {@link SkipSslVerificationHttpRequestFactory}.
|
||||
*/
|
||||
class SkipSslVerificationHttpRequestFactoryTests {
|
||||
|
||||
private WebServer webServer;
|
||||
|
||||
@AfterEach
|
||||
void shutdownContainer() {
|
||||
if (this.webServer != null) {
|
||||
this.webServer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@WithPackageResources("test.jks")
|
||||
void restCallToSelfSignedServerShouldNotThrowSslException() {
|
||||
String httpsUrl = getHttpsUrl();
|
||||
SkipSslVerificationHttpRequestFactory requestFactory = new SkipSslVerificationHttpRequestFactory();
|
||||
RestTemplate restTemplate = new RestTemplate(requestFactory);
|
||||
RestTemplate otherRestTemplate = new RestTemplate();
|
||||
ResponseEntity<String> responseEntity = restTemplate.getForEntity(httpsUrl, String.class);
|
||||
assertThat(responseEntity.getStatusCode()).isEqualTo(HttpStatus.OK);
|
||||
assertThatExceptionOfType(ResourceAccessException.class)
|
||||
.isThrownBy(() -> otherRestTemplate.getForEntity(httpsUrl, String.class))
|
||||
.withCauseInstanceOf(SSLHandshakeException.class);
|
||||
}
|
||||
|
||||
private String getHttpsUrl() {
|
||||
TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory(0);
|
||||
factory.setSsl(getSsl("password", "classpath:test.jks"));
|
||||
this.webServer = factory.getWebServer(new ServletRegistrationBean<>(new ExampleServlet(), "/hello"));
|
||||
this.webServer.start();
|
||||
return "https://localhost:" + this.webServer.getPort() + "/hello";
|
||||
}
|
||||
|
||||
private Ssl getSsl(String keyPassword, String keyStore) {
|
||||
Ssl ssl = new Ssl();
|
||||
ssl.setEnabled(true);
|
||||
ssl.setKeyPassword(keyPassword);
|
||||
ssl.setKeyStore(keyStore);
|
||||
ssl.setKeyStorePassword("secret");
|
||||
return ssl;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/*
|
||||
* Copyright 2012-2025 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
|
||||
*
|
||||
* https://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.cloudfoundry.actuate.autoconfigure.servlet;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
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.Base64;
|
||||
import java.util.Collections;
|
||||
import java.util.Map;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.CloudFoundryAuthorizationException.Reason;
|
||||
import org.springframework.boot.cloudfoundry.actuate.autoconfigure.Token;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
import org.springframework.util.StreamUtils;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
||||
import static org.mockito.BDDMockito.given;
|
||||
import static org.mockito.BDDMockito.then;
|
||||
import static org.mockito.Mockito.never;
|
||||
|
||||
/**
|
||||
* Tests for {@link TokenValidator}.
|
||||
*
|
||||
* @author Madhura Bhave
|
||||
*/
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class TokenValidatorTests {
|
||||
|
||||
private static final byte[] DOT = ".".getBytes();
|
||||
|
||||
@Mock
|
||||
private CloudFoundrySecurityService securityService;
|
||||
|
||||
private TokenValidator tokenValidator;
|
||||
|
||||
private static final String VALID_KEY = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0m59l2u9iDnMbrXHfqkO
|
||||
rn2dVQ3vfBJqcDuFUK03d+1PZGbVlNCqnkpIJ8syFppW8ljnWweP7+LiWpRoz0I7
|
||||
fYb3d8TjhV86Y997Fl4DBrxgM6KTJOuE/uxnoDhZQ14LgOU2ckXjOzOdTsnGMKQB
|
||||
LCl0vpcXBtFLMaSbpv1ozi8h7DJyVZ6EnFQZUWGdgTMhDrmqevfx95U/16c5WBDO
|
||||
kqwIn7Glry9n9Suxygbf8g5AzpWcusZgDLIIZ7JTUldBb8qU2a0Dl4mvLZOn4wPo
|
||||
jfj9Cw2QICsc5+Pwf21fP+hzf+1WSRHbnYv8uanRO0gZ8ekGaghM/2H6gqJbo2nI
|
||||
JwIDAQAB
|
||||
-----END PUBLIC KEY-----""";
|
||||
|
||||
private static final String INVALID_KEY = """
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxzYuc22QSst/dS7geYYK
|
||||
5l5kLxU0tayNdixkEQ17ix+CUcUbKIsnyftZxaCYT46rQtXgCaYRdJcbB3hmyrOa
|
||||
vkhTpX79xJZnQmfuamMbZBqitvscxW9zRR9tBUL6vdi/0rpoUwPMEh8+Bw7CgYR0
|
||||
FK0DhWYBNDfe9HKcyZEv3max8Cdq18htxjEsdYO0iwzhtKRXomBWTdhD5ykd/fAC
|
||||
VTr4+KEY+IeLvubHVmLUhbE5NgWXxrRpGasDqzKhCTmsa2Ysf712rl57SlH0Wz/M
|
||||
r3F7aM9YpErzeYLrl0GhQr9BVJxOvXcVd4kmY+XkiCcrkyS1cnghnllh+LCwQu1s
|
||||
YwIDAQAB
|
||||
-----END PUBLIC KEY-----""";
|
||||
|
||||
private static final Map<String, String> INVALID_KEYS = Collections.singletonMap("invalid-key", INVALID_KEY);
|
||||
|
||||
private static final Map<String, String> VALID_KEYS = Collections.singletonMap("valid-key", VALID_KEY);
|
||||
|
||||
@BeforeEach
|
||||
void setup() {
|
||||
this.tokenValidator = new TokenValidator(this.securityService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenKidValidationFailsTwiceShouldThrowException() {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(INVALID_KEYS);
|
||||
String header = "{\"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{\"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(
|
||||
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_KEY_ID));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenKidValidationSucceedsInTheSecondAttempt() throws Exception {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", INVALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
then(this.securityService).should().fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenShouldFetchTokenKeysIfNull() throws Exception {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
then(this.securityService).should().fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenValidShouldNotFetchTokenKeys() throws Exception {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys", VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes())));
|
||||
then(this.securityService).should(never()).fetchTokenKeys();
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenSignatureInvalidShouldThrowException() {
|
||||
ReflectionTestUtils.setField(this.tokenValidator, "tokenKeys",
|
||||
Collections.singletonMap("valid-key", INVALID_KEY));
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\",\"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(
|
||||
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_SIGNATURE));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenTokenAlgorithmIsNotRS256ShouldThrowException() {
|
||||
String header = "{ \"alg\": \"HS256\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"actuator.read\"]}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(
|
||||
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.satisfies(reasonRequirement(Reason.UNSUPPORTED_TOKEN_SIGNING_ALGORITHM));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenExpiredShouldThrowException() {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"jti\": \"0236399c350c47f3ae77e67a75e75e7d\", \"exp\": 1477509977, \"scope\": [\"actuator.read\"]}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(
|
||||
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.satisfies(reasonRequirement(Reason.TOKEN_EXPIRED));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenIssuerIsNotValidShouldThrowException() {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("https://other-uaa.com");
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\", \"scope\": [\"actuator.read\"]}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\"}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(
|
||||
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_ISSUER));
|
||||
}
|
||||
|
||||
@Test
|
||||
void validateTokenWhenAudienceIsNotValidShouldThrowException() {
|
||||
given(this.securityService.fetchTokenKeys()).willReturn(VALID_KEYS);
|
||||
given(this.securityService.getUaaUrl()).willReturn("http://localhost:8080/uaa");
|
||||
String header = "{ \"alg\": \"RS256\", \"kid\": \"valid-key\", \"typ\": \"JWT\"}";
|
||||
String claims = "{ \"exp\": 2147483647, \"iss\": \"http://localhost:8080/uaa/oauth/token\", \"scope\": [\"foo.bar\"]}";
|
||||
assertThatExceptionOfType(CloudFoundryAuthorizationException.class)
|
||||
.isThrownBy(
|
||||
() -> this.tokenValidator.validate(new Token(getSignedToken(header.getBytes(), claims.getBytes()))))
|
||||
.satisfies(reasonRequirement(Reason.INVALID_AUDIENCE));
|
||||
}
|
||||
|
||||
private String getSignedToken(byte[] header, byte[] claims) throws Exception {
|
||||
PrivateKey privateKey = getPrivateKey();
|
||||
Signature signature = Signature.getInstance("SHA256WithRSA");
|
||||
signature.initSign(privateKey);
|
||||
byte[] content = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getEncoder().encode(claims));
|
||||
signature.update(content);
|
||||
byte[] crypto = signature.sign();
|
||||
byte[] token = dotConcat(Base64.getUrlEncoder().encode(header), Base64.getUrlEncoder().encode(claims),
|
||||
Base64.getUrlEncoder().encode(crypto));
|
||||
return new String(token, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private PrivateKey getPrivateKey() throws InvalidKeySpecException, NoSuchAlgorithmException {
|
||||
String signingKey = """
|
||||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDSbn2Xa72IOcxu
|
||||
tcd+qQ6ufZ1VDe98EmpwO4VQrTd37U9kZtWU0KqeSkgnyzIWmlbyWOdbB4/v4uJa
|
||||
lGjPQjt9hvd3xOOFXzpj33sWXgMGvGAzopMk64T+7GegOFlDXguA5TZyReM7M51O
|
||||
ycYwpAEsKXS+lxcG0UsxpJum/WjOLyHsMnJVnoScVBlRYZ2BMyEOuap69/H3lT/X
|
||||
pzlYEM6SrAifsaWvL2f1K7HKBt/yDkDOlZy6xmAMsghnslNSV0FvypTZrQOXia8t
|
||||
k6fjA+iN+P0LDZAgKxzn4/B/bV8/6HN/7VZJEdudi/y5qdE7SBnx6QZqCEz/YfqC
|
||||
olujacgnAgMBAAECggEAc9X2tJ/OWWrXqinOg160gkELloJxTi8lAFsDbAGuAwpT
|
||||
JcWl1KF5CmGBjsY/8ElNi2J9GJL1HOwcBhikCVNARD1DhF6RkB13mvquWwWtTMvt
|
||||
eP8JWM19DIc+E+hw2rCuTGngqs7l4vTqpzBTNPtS2eiIJ1IsjsgvSEiAlk/wnW48
|
||||
11cf6SQMQcT3HNTWrS+yLycEuWKb6Khh8RpD9D+i8w2+IspWz5lTP7BrKCUNsLOx
|
||||
6+5T52HcaZ9z3wMnDqfqIKWl3h8M+q+HFQ4EN5BPWYV4fF7EOx7+Qf2fKDFPoTjC
|
||||
VTWzDRNAA1xPqwdF7IdPVOXCdaUJDOhHeXZGaTNSwQKBgQDxb9UiR/Jh1R3muL7I
|
||||
neIt1gXa0O+SK7NWYl4DkArYo7V81ztxI8r+xKEeu5zRZZkpaJHxOnd3VfADascw
|
||||
UfALvxGxN2z42lE6zdhrmxZ3ma+akQFsv7NyXcBT00sdW+xmOiCaAj0cgxNOXiV3
|
||||
sYOwUy3SqUIPO2obpb+KC5ALHwKBgQDfH+NSQ/jn89oVZ3lzUORa+Z+aL1TGsgzs
|
||||
p7IG0MTEYiR9/AExYUwJab0M4PDXhumeoACMfkCFALNVhpch2nXZv7X5445yRgfD
|
||||
ONY4WknecuA0rfCLTruNWnQ3RR+BXmd9jD/5igd9hEIawz3V+jCHvAtzI8/CZIBt
|
||||
AArBs5kp+QKBgQCdxwN1n6baIDemK10iJWtFoPO6h4fH8h8EeMwPb/ZmlLVpnA4Q
|
||||
Zd+mlkDkoJ5eiRKKaPfWuOqRZeuvj/wTq7g/NOIO+bWQ+rrSvuqLh5IrHpgPXmub
|
||||
8bsHJhUlspMH4KagN6ROgOAG3fGj6Qp7KdpxRCpR3KJ66czxvGNrhxre6QKBgB+s
|
||||
MCGiYnfSprd5G8VhyziazKwfYeJerfT+DQhopDXYVKPJnQW8cQW5C8wDNkzx6sHI
|
||||
pqtK1K/MnKhcVaHJmAcT7qoNQlA4Xqu4qrgPIQNBvU/dDRNJVthG6c5aspEzrG8m
|
||||
9IHgtRV9K8EOy/1O6YqrB9kNUVWf3JccdWpvqyNJAoGAORzJiQCOk4egbdcozDTo
|
||||
4Tg4qk/03qpTy5k64DxkX1nJHu8V/hsKwq9Af7Fj/iHy2Av54BLPlBaGPwMi2bzB
|
||||
gYjmUomvx/fqOTQks9Rc4PIMB43p6Rdj0sh+52SKPDR2eHbwsmpuQUXnAs20BPPI
|
||||
J/OOn5zOs8yf26os0q3+JUM=
|
||||
-----END PRIVATE KEY-----""";
|
||||
String privateKey = signingKey.replace("-----BEGIN PRIVATE KEY-----\n", "");
|
||||
privateKey = privateKey.replace("-----END PRIVATE KEY-----", "");
|
||||
privateKey = privateKey.replace("\n", "");
|
||||
byte[] pkcs8EncodedBytes = Base64.getDecoder().decode(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();
|
||||
}
|
||||
|
||||
private Consumer<CloudFoundryAuthorizationException> reasonRequirement(Reason reason) {
|
||||
return (ex) -> assertThat(ex.getReason()).isEqualTo(reason);
|
||||
}
|
||||
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Reference in New Issue
Block a user