Merge pull request #1093 from daniellavoie/hystrix-security

Expose security context to any Hystrix command.
This commit is contained in:
Ryan Baxter
2016-08-10 15:20:19 -04:00
committed by GitHub
16 changed files with 501 additions and 10 deletions

View File

@@ -507,6 +507,8 @@ If you want some thread local context to propagate into a `@HystrixCommand` the
The same thing applies if you are using `@SessionScope` or `@RequestScope`. You will know when you need to do this because of a runtime exception that says it can't find the scoped context.
You also have the option to set the `hystrix.shareSecurityContext` property to `true`. Doing so will auto configure an Hystrix concurrency strategy plugin hook who will transfer the `SecurityContext` from your main thread to the one used by the Hystrix command. Hystrix does not allow multiple hystrix concurrency strategy to be registered so an extension mechanism is available by declaring your own `HystrixConcurrencyStrategy` as a Spring bean. Spring Cloud will lookup for your implementation within the Spring context and wrap it inside its own plugin.
### Health Indicator
The state of the connected circuit breakers are also exposed in the

View File

@@ -34,6 +34,11 @@
<artifactId>spring-boot-starter-actuator</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security;
import javax.annotation.PostConstruct;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.AllNestedConditions;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.cloud.netflix.hystrix.security.HystrixSecurityAutoConfiguration.HystrixSecurityCondition;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.context.SecurityContext;
import com.netflix.hystrix.Hystrix;
import com.netflix.hystrix.strategy.HystrixPlugins;
import com.netflix.hystrix.strategy.concurrency.HystrixConcurrencyStrategy;
import com.netflix.hystrix.strategy.eventnotifier.HystrixEventNotifier;
import com.netflix.hystrix.strategy.executionhook.HystrixCommandExecutionHook;
import com.netflix.hystrix.strategy.metrics.HystrixMetricsPublisher;
import com.netflix.hystrix.strategy.properties.HystrixPropertiesStrategy;
/**
* @author Daniel Lavoie
*/
@Configuration
@Conditional(HystrixSecurityCondition.class)
@ConditionalOnClass({ Hystrix.class, SecurityContext.class })
public class HystrixSecurityAutoConfiguration {
@Autowired(required = false)
private HystrixConcurrencyStrategy existingConcurrencyStrategy;
@PostConstruct
public void init() {
// Keeps references of existing Hystrix plugins.
HystrixEventNotifier eventNotifier = HystrixPlugins.getInstance()
.getEventNotifier();
HystrixMetricsPublisher metricsPublisher = HystrixPlugins.getInstance()
.getMetricsPublisher();
HystrixPropertiesStrategy propertiesStrategy = HystrixPlugins.getInstance()
.getPropertiesStrategy();
HystrixCommandExecutionHook commandExecutionHook = HystrixPlugins.getInstance()
.getCommandExecutionHook();
HystrixPlugins.reset();
// Registers existing plugins excepts the Concurrent Strategy plugin.
HystrixPlugins.getInstance().registerConcurrencyStrategy(
new SecurityContextConcurrencyStrategy(existingConcurrencyStrategy));
HystrixPlugins.getInstance().registerEventNotifier(eventNotifier);
HystrixPlugins.getInstance().registerMetricsPublisher(metricsPublisher);
HystrixPlugins.getInstance().registerPropertiesStrategy(propertiesStrategy);
HystrixPlugins.getInstance().registerCommandExecutionHook(commandExecutionHook);
}
static class HystrixSecurityCondition extends AllNestedConditions {
public HystrixSecurityCondition() {
super(ConfigurationPhase.REGISTER_BEAN);
}
@ConditionalOnProperty(name = "feign.hystrix.enabled", matchIfMissing = true)
static class HystrixEnabled {
}
@ConditionalOnProperty(name = "hystrix.shareSecurityContext")
static class ShareSecurityContext {
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Callable;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import org.springframework.security.concurrent.DelegatingSecurityContextCallable;
import com.netflix.hystrix.HystrixThreadPoolKey;
import com.netflix.hystrix.strategy.concurrency.HystrixConcurrencyStrategy;
import com.netflix.hystrix.strategy.concurrency.HystrixRequestVariable;
import com.netflix.hystrix.strategy.concurrency.HystrixRequestVariableLifecycle;
import com.netflix.hystrix.strategy.properties.HystrixProperty;
/**
* @author daniellavoie
*/
public class SecurityContextConcurrencyStrategy extends HystrixConcurrencyStrategy {
private HystrixConcurrencyStrategy existingConcurrencyStrategy;
public SecurityContextConcurrencyStrategy(
HystrixConcurrencyStrategy existingConcurrencyStrategy) {
this.existingConcurrencyStrategy = existingConcurrencyStrategy;
}
@Override
public BlockingQueue<Runnable> getBlockingQueue(int maxQueueSize) {
return existingConcurrencyStrategy != null
? existingConcurrencyStrategy.getBlockingQueue(maxQueueSize)
: super.getBlockingQueue(maxQueueSize);
}
@Override
public <T> HystrixRequestVariable<T> getRequestVariable(
HystrixRequestVariableLifecycle<T> rv) {
return existingConcurrencyStrategy != null
? existingConcurrencyStrategy.getRequestVariable(rv)
: super.getRequestVariable(rv);
}
@Override
public ThreadPoolExecutor getThreadPool(HystrixThreadPoolKey threadPoolKey,
HystrixProperty<Integer> corePoolSize,
HystrixProperty<Integer> maximumPoolSize,
HystrixProperty<Integer> keepAliveTime, TimeUnit unit,
BlockingQueue<Runnable> workQueue) {
return existingConcurrencyStrategy != null
? existingConcurrencyStrategy.getThreadPool(threadPoolKey, corePoolSize,
maximumPoolSize, keepAliveTime, unit, workQueue)
: super.getThreadPool(threadPoolKey, corePoolSize, maximumPoolSize,
keepAliveTime, unit, workQueue);
}
@Override
public <T> Callable<T> wrapCallable(Callable<T> callable) {
return existingConcurrencyStrategy != null
? existingConcurrencyStrategy
.wrapCallable(new DelegatingSecurityContextCallable<T>(callable))
: super.wrapCallable(new DelegatingSecurityContextCallable<T>(callable));
}
}

View File

@@ -48,7 +48,7 @@ public class ZuulProperties {
* duplicated if the proxy and the backend are secured with Spring. By default they
* are added to the ignored headers if Spring Security is present.
*/
private static final List<String> SECURITY_HEADERS = Arrays.asList("Pragma",
public static final List<String> SECURITY_HEADERS = Arrays.asList("Pragma",
"Cache-Control", "X-Frame-Options", "X-Content-Type-Options",
"X-XSS-Protection", "Expires");

View File

@@ -5,6 +5,7 @@ org.springframework.cloud.netflix.feign.FeignAutoConfiguration,\
org.springframework.cloud.netflix.feign.encoding.FeignAcceptGzipEncodingAutoConfiguration,\
org.springframework.cloud.netflix.feign.encoding.FeignContentGzipEncodingAutoConfiguration,\
org.springframework.cloud.netflix.hystrix.HystrixAutoConfiguration,\
org.springframework.cloud.netflix.hystrix.security.HystrixSecurityAutoConfiguration,\
org.springframework.cloud.netflix.ribbon.RibbonAutoConfiguration,\
org.springframework.cloud.netflix.rx.RxJavaAutoConfiguration,\
org.springframework.cloud.netflix.metrics.servo.ServoMetricsAutoConfiguration

View File

@@ -16,6 +16,11 @@
package org.springframework.cloud.netflix.hystrix;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import java.util.Base64;
import java.util.Map;
import org.junit.Test;
@@ -30,6 +35,9 @@ import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.cloud.client.circuitbreaker.EnableCircuitBreaker;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -37,10 +45,6 @@ import org.springframework.web.bind.annotation.RestController;
import com.netflix.hystrix.contrib.javanica.annotation.HystrixCommand;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
/**
* @author Spencer Gibb
*/
@@ -51,6 +55,12 @@ public class HystrixOnlyTests {
@Value("${local.server.port}")
private int port;
@Value("${security.user.username}")
private String username;
@Value("${security.user.password}")
private String password;
@Test
public void testNormalExecution() {
@@ -82,9 +92,27 @@ public class HystrixOnlyTests {
map.containsKey("discovery"));
}
private Map<?, ?> getHealth() {
return new TestRestTemplate().getForObject("http://localhost:" + this.port
+ "/admin/health", Map.class);
return new TestRestTemplate().exchange(
"http://localhost:" + this.port + "/admin/health", HttpMethod.GET,
new HttpEntity<Void>(createBasicAuthHeader(username, password)),
Map.class).getBody();
}
public static HttpHeaders createBasicAuthHeader(final String username,
final String password) {
return new HttpHeaders() {
private static final long serialVersionUID = 1766341693637204893L;
{
String auth = username + ":" + password;
byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes());
String authHeader = "Basic " + new String(encodedAuth);
this.set("Authorization", authHeader);
}
};
}
}

View File

@@ -0,0 +1,32 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.feign.EnableFeignClients;
import org.springframework.cloud.netflix.hystrix.security.app.UsernameClient;
import org.springframework.context.annotation.Configuration;
/**
* @author Daniel Lavoie
*/
@Configuration
@SpringBootApplication
@EnableFeignClients(clients = UsernameClient.class)
public class HystrixSecurityApplication {
}

View File

@@ -0,0 +1,87 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security;
import java.util.Base64;
import org.junit.Assert;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.cloud.netflix.hystrix.security.app.CustomConcurrenyStrategy;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.test.annotation.DirtiesContext;
import org.springframework.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.web.client.RestTemplate;
/**
* Tests that a secured web service returning values using a feign client properly access
* the security context from a hystrix command.
* @author Daniel Lavoie
*/
@RunWith(SpringJUnit4ClassRunner.class)
@DirtiesContext
@SpringBootTest(classes = HystrixSecurityApplication.class, webEnvironment = WebEnvironment.RANDOM_PORT, properties = "username.ribbon.listOfServers=localhost:${local.server.port}")
public class HystrixSecurityTests {
@Autowired
private CustomConcurrenyStrategy customConcurrenyStrategy;
@Value("${local.server.port}")
private String serverPort;
@Value("${security.user.username}")
private String username;
@Value("${security.user.password}")
private String password;
@Test
public void testFeignHystrixSecurity() {
HttpHeaders headers = HystrixSecurityTests.createBasicAuthHeader(username,
password);
String usernameResult = new RestTemplate()
.exchange("http://localhost:" + serverPort + "/proxy-username",
HttpMethod.GET, new HttpEntity<Void>(headers), String.class)
.getBody();
Assert.assertTrue("Username should have been intercepted by feign interceptor.",
username.equals(usernameResult));
Assert.assertTrue("Custom hook should have been called.",
customConcurrenyStrategy.isHookCalled());
}
public static HttpHeaders createBasicAuthHeader(final String username,
final String password) {
return new HttpHeaders() {
private static final long serialVersionUID = 1766341693637204893L;
{
String auth = username + ":" + password;
byte[] encodedAuth = Base64.getEncoder().encode(auth.getBytes());
String authHeader = "Basic " + new String(encodedAuth);
this.set("Authorization", authHeader);
}
};
}
}

View File

@@ -0,0 +1,23 @@
package org.springframework.cloud.netflix.hystrix.security.app;
import java.util.concurrent.Callable;
import org.springframework.stereotype.Component;
import com.netflix.hystrix.strategy.concurrency.HystrixConcurrencyStrategy;
@Component
public class CustomConcurrenyStrategy extends HystrixConcurrencyStrategy {
private boolean hookCalled;
@Override
public <T> Callable<T> wrapCallable(Callable<T> callable) {
this.hookCalled = true;
return super.wrapCallable(callable);
}
public boolean isHookCalled() {
return hookCalled;
}
}

View File

@@ -0,0 +1,36 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security.app;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Daniel Lavoie
*/
@RestController
@RequestMapping("/proxy-username")
public class ProxyUsernameController {
@Autowired
private UsernameClient usernameClient;
@RequestMapping
public String getUsername() {
return usernameClient.getUsername();
}
}

View File

@@ -0,0 +1,40 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security.app;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import feign.RequestInterceptor;
import feign.RequestTemplate;
/**
* This interceptor should be called from an Hyxtrix command execution thread. It is
* access the SecurityContext and settings an http header from the authentication details.
*
* @author Daniel Lavoie
*/
@Component
public class TestInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
if (SecurityContextHolder.getContext().getAuthentication() != null)
template.header("username",
SecurityContextHolder.getContext().getAuthentication().getName());
}
}

View File

@@ -0,0 +1,30 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security.app;
import org.springframework.cloud.netflix.feign.FeignClient;
import org.springframework.web.bind.annotation.RequestMapping;
/**
* @author Daniel Lavoie
*/
@FeignClient("username")
public interface UsernameClient {
@RequestMapping("/username")
public String getUsername();
}

View File

@@ -0,0 +1,33 @@
/*
* Copyright 2013-2016 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.cloud.netflix.hystrix.security.app;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* @author Daniel Lavoie
*/
@RestController
@RequestMapping("/username")
public class UsernameController {
@RequestMapping
public String getUsername(@RequestHeader String username){
return username;
}
}

View File

@@ -46,7 +46,8 @@ public class ZuulPropertiesTests {
@Test
public void defaultIgnoredHeaders() {
assertTrue(this.zuul.getIgnoredHeaders().isEmpty());
assertTrue(this.zuul.getIgnoredHeaders()
.containsAll(ZuulProperties.SECURITY_HEADERS));
}
@Test
@@ -60,8 +61,8 @@ public class ZuulPropertiesTests {
ZuulRoute route = new ZuulRoute("foo");
this.zuul.getRoutes().put("foo", route);
assertTrue(this.zuul.getRoutes().get("foo").getSensitiveHeaders().isEmpty());
assertTrue(this.zuul.getSensitiveHeaders().containsAll(
Arrays.asList("Cookie", "Set-Cookie", "Authorization")));
assertTrue(this.zuul.getSensitiveHeaders()
.containsAll(Arrays.asList("Cookie", "Set-Cookie", "Authorization")));
}
@Test

View File

@@ -53,7 +53,15 @@ zuul:
stores:
url: http://localhost:8081
path: /stores/**
hystrix:
shareSecurityContext: true
feignClient:
localappName: localapp
methodLevelRequestMappingPath: /hello2
myPlaceholderHeader: myPlaceholderHeaderValue
security:
basic:
path: /proxy-username
user:
username: user
password: password